diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index ded9142b36..27d7716d67 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -394,9 +394,6 @@ de: inline_help: Farbe des Bereichs der Waveform, der den bereits abgespielten Teil der Audio-Datei repräsentiert. label: Waveform Farbe description: Wiedergabe einer Audiodatei mit Steuerelementen - name: Inline-Audio - tabs: - general: Inline-Audio inlineBeforeAfter: attributes: after_id: @@ -433,9 +430,6 @@ de: inline_help: Wird gezeigt, wenn der Browser-Viewport höher als breit ist - zum Beispiel auf Smartphones oder Tablets in Portrait-Ausrichtung. Kann als Alternative zu einem querformatigen Bild konfiguriert werden, das ansonsten zu klein dargestellt würde. label: Bild (Hochkant) description: Einbindung von Bildern - name: Inline-Bild - tabs: - general: Inline-Bild inlineVideo: attributes: atmoDuringPlayback: @@ -481,9 +475,6 @@ de: inline_help_html: Browser erlauben standardmäßig das Abspielen von Videos mit Ton nur nach einer Benutzer-Interaktion. Um Videos dennoch automatisch abspielen zu können, sind Beiträge zu Beginn stummgeschaltet.

Die Stummschaltung kann z.B. über das Lautsprecher-Icon in der Navigationsleiste oder ein im Beitrag platziertes "Audio-Hinweis"-Element aufgehoben werden. label: Stummschaltung des Beitrags description: Wiedergabe einer Videodatei mit Steuerelementen - name: Inline-Video - tabs: - general: Inline-Video question: attributes: expandByDefault: @@ -683,7 +674,6 @@ de: values: narrow: Schmal wide: Breit - edit_motif_area: Motivbereich markieren... tabs: section: Abschnitt edit_section_transition: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index ea8465fa81..13c5e0145d 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -385,9 +385,6 @@ en: inline_help: Color of the waveform's parts that represents the already played part of the audio. label: Waveform Color description: Player or waveform with controls - name: Inline Audio - tabs: - general: Inline Audio inlineBeforeAfter: attributes: after_id: @@ -424,9 +421,6 @@ en: inline_help: Displayed when the browser viewport is taller than wide, for example on phones or tablets in portrait orientation. Can be used to provide an alternative to a landscape image that would otherwise be displayed too small. label: Image (Portrait) description: Display an image - name: Inline Image - tabs: - general: Inline Image inlineVideo: attributes: atmoDuringPlayback: @@ -472,9 +466,6 @@ en: inline_help_html: By default browsers only allow autoplaying videos with sound after a user interaction. To still be able to autoplay videos, entries therefore start muted.

Sound can be activated using the speaker icon in the navigation bar or via an "Audio notice" element that has been placed in the entry. label: Unmute entry description: Player with controls - name: Inline Video - tabs: - general: Inline Video question: attributes: expandByDefault: @@ -674,7 +665,6 @@ en: values: narrow: Narrow wide: Wide - edit_motif_area: Select motif area... tabs: section: Section edit_section_transition: diff --git a/entry_types/scrolled/config/locales/new/backdrop-position.de.yml b/entry_types/scrolled/config/locales/new/backdrop-position.de.yml new file mode 100644 index 0000000000..071cd67056 --- /dev/null +++ b/entry_types/scrolled/config/locales/new/backdrop-position.de.yml @@ -0,0 +1,43 @@ +de: + pageflow: + backdrop_content_elements: + feature_name: "Inhaltselemente als Backdrop" + pageflow_scrolled: + editor: + common_content_element_attributes: + position: + values: + backdrop: 'Als Hintergrund' + item_inline_help_texts: + backdrop: '' + content_elements: + inlineVideo: + name: Video + tabs: + general: Video + inlineAudio: + name: Audio + tabs: + general: Audio + inlineImage: + name: Bild + tabs: + general: Bild + edit_section: + attributes: + backdropType: + values: + contentElement: Interaktives Element + fullHeight: + inline_help_disabled: |- + Interaktive Hintergrund-Elemente nutzen immer die volle Viewport-Höhe + backdropContentElement: + label: Element + edit_motif_area: DELETED + edit_motif_area_menu_item: Motivbereich markieren... + backdrop_content_element_input: + add: Neues Element + unset: Nicht mehr als Hintergrund verwenden + inline_editing: + select_backdrop_content_element: Hintergrund bearbeiten + back_to_section: Zurück zum Abschnitt diff --git a/entry_types/scrolled/config/locales/new/backdrop-position.en.yml b/entry_types/scrolled/config/locales/new/backdrop-position.en.yml new file mode 100644 index 0000000000..abd02dbe72 --- /dev/null +++ b/entry_types/scrolled/config/locales/new/backdrop-position.en.yml @@ -0,0 +1,43 @@ +en: + pageflow: + backdrop_content_elements: + feature_name: "Backdrop content elements" + pageflow_scrolled: + editor: + common_content_element_attributes: + position: + values: + backdrop: 'As Backdrop' + item_inline_help_texts: + backdrop: '' + content_elements: + inlineVideo: + name: Video + tabs: + general: Video + inlineAudio: + name: Audio + tabs: + general: Audio + inlineImage: + name: Image + tabs: + general: Image + edit_section: + attributes: + backdropType: + values: + contentElement: Interactive Element + fullHeight: + inline_help_disabled: |- + Interactive backdrop elements always take up full viewport height. + backdropContentElement: + label: Element + edit_motif_area: DELETED + edit_motif_area_menu_item: Select motif area... + backdrop_content_element_input: + add: New element + unset: No longer use as backdrop + inline_editing: + select_backdrop_content_element: Edit backdrop + back_to_section: Back to section diff --git a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb index 41c2f3956c..91d1640e01 100644 --- a/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb +++ b/entry_types/scrolled/lib/pageflow_scrolled/plugin.rb @@ -38,6 +38,7 @@ def configure(config) c.features.register('image_gallery_content_element') c.features.register('frontend_v2') c.features.register('scrolled_entry_fragment_caching') + c.features.register('backdrop_content_elements') c.additional_frontend_seed_data.register( 'frontendVersion', diff --git a/entry_types/scrolled/package/spec/editor/api/ContentElementTypeRegistry-spec.js b/entry_types/scrolled/package/spec/editor/api/ContentElementTypeRegistry-spec.js index d4fa18b912..f6f902b42c 100644 --- a/entry_types/scrolled/package/spec/editor/api/ContentElementTypeRegistry-spec.js +++ b/entry_types/scrolled/package/spec/editor/api/ContentElementTypeRegistry-spec.js @@ -11,6 +11,25 @@ describe('ContentElementTypeRegistry', () => { 'pageflow_scrolled.editor.content_elements.inlineImage.description': 'An image' }); + describe('findByTypeName', () => { + it('includes translated display name and description', () => { + const registry = new ContentElementTypeRegistry({features: new Features()}); + registry.register('inlineImage', { + category: 'media', + supportedPositions: ['inline', 'full'] + }); + + const contentElementType = registry.findByTypeName('inlineImage'); + + expect(contentElementType).toMatchObject({ + category: 'media', + supportedPositions: ['inline', 'full'], + displayName: 'Inline image', + description: 'An image' + }); + }); + }); + describe('groupedByCategory', () => { it('returns options passed to register grouped by category', () => { const registry = new ContentElementTypeRegistry({features: new Features()}); diff --git a/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js b/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js index b8016a2079..76709cdcdb 100644 --- a/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js +++ b/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js @@ -71,8 +71,29 @@ describe('PreviewMessageController', () => { resolve(event.data); } }); - entry.trigger('scrollToSection', entry.sections.at(2)); - })).resolves.toMatchObject({type: 'SCROLL_TO_SECTION', payload: {index: 2}}); + entry.trigger('scrollToSection', entry.sections.get(2)); + })).resolves.toMatchObject({type: 'SCROLL_TO_SECTION', payload: {id: 2}}); + }); + + it('supports sending SCROLL_TO_SECTION message with align property', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 1}, {id: 2}, {id: 3}] + }) + }); + const iframeWindow = createIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow}); + + await postReadyMessageAndWaitForAcknowledgement(iframeWindow); + + return expect(new Promise(resolve => { + iframeWindow.addEventListener('message', event => { + if (event.data.type === 'SCROLL_TO_SECTION') { + resolve(event.data); + } + }); + entry.trigger('scrollToSection', entry.sections.get(2), {align: 'start'}); + })).resolves.toMatchObject({type: 'SCROLL_TO_SECTION', payload: {id: 2, align: 'start'}}); }); it('sends SELECT message to iframe on selectContentElement event on model', async () => { diff --git a/entry_types/scrolled/package/spec/editor/models/ContentElement-spec.js b/entry_types/scrolled/package/spec/editor/models/ContentElement-spec.js index 7a346c4426..b2f5ef45c8 100644 --- a/entry_types/scrolled/package/spec/editor/models/ContentElement-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/ContentElement-spec.js @@ -1,10 +1,13 @@ import {editor} from 'pageflow-scrolled/editor'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; import {factories, normalizeSeed} from 'support'; +import {features} from 'pageflow/frontend'; describe('ContentElement', () => { describe('getAvailablePositions', () => { beforeEach(() => { + features.enable('frontend', ['backdrop_content_elements']); + editor.contentElementTypes.register('inlineImage', {}); editor.contentElementTypes.register('soundDisclaimer', {supportedPositions: ['inline']}); }); @@ -26,7 +29,9 @@ describe('ContentElement', () => { ); const contentElement = entry.contentElements.get(5); - expect(contentElement.getAvailablePositions()).toEqual(['inline', 'sticky', 'standAlone']); + expect(contentElement.getAvailablePositions()).toEqual( + ['inline', 'sticky', 'standAlone', 'backdrop'] + ); }); it('returns positions for left layout if parent section uses that', () => { @@ -46,7 +51,9 @@ describe('ContentElement', () => { ); const contentElement = entry.contentElements.get(5); - expect(contentElement.getAvailablePositions()).toEqual(['inline', 'sticky', 'standAlone']); + expect(contentElement.getAvailablePositions()).toEqual( + ['inline', 'sticky', 'standAlone', 'backdrop'] + ); }); it('returns positions for center layout if parent section uses that', () => { @@ -66,7 +73,9 @@ describe('ContentElement', () => { ); const contentElement = entry.contentElements.get(5); - expect(contentElement.getAvailablePositions()).toEqual(['inline', 'left', 'right', 'standAlone']); + expect(contentElement.getAvailablePositions()).toEqual( + ['inline', 'left', 'right', 'standAlone', 'backdrop'] + ); }); it('returns positions for centerRagged layout if parent section uses that', () => { @@ -86,7 +95,9 @@ describe('ContentElement', () => { ); const contentElement = entry.contentElements.get(5); - expect(contentElement.getAvailablePositions()).toEqual(['inline', 'left', 'right', 'standAlone']); + expect(contentElement.getAvailablePositions()).toEqual( + ['inline', 'left', 'right', 'standAlone', 'backdrop'] + ); }); it('filters by positions supported by content element type', () => { @@ -331,6 +342,27 @@ describe('ContentElement', () => { expect(contentElement.getAvailableMaxWidth()).toEqual(2); }); + it('only offer md position in backdrop position', () => { + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + sections: [ + {id: 1} + ], + contentElements: [ + {id: 5, sectionId: 1, typeName: 'inlineImage', configuration: {position: 'backdrop'}} + ] + }) + } + ); + const contentElement = entry.contentElements.get(5); + + expect(contentElement.getAvailableMinWidth()).toEqual(0); + expect(contentElement.getAvailableMaxWidth()).toEqual(0); + }); + it('does not exclude xxs/full if position is not supported by layout', () => { const entry = factories.entry( ScrolledEntry, diff --git a/entry_types/scrolled/package/spec/editor/models/ContentElementConfiguration-spec.js b/entry_types/scrolled/package/spec/editor/models/ContentElementConfiguration-spec.js new file mode 100644 index 0000000000..7a0a18d9bb --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/ContentElementConfiguration-spec.js @@ -0,0 +1,142 @@ +import {ScrolledEntry} from 'editor/models/ScrolledEntry'; +import {factories, normalizeSeed} from 'support'; +import {setupGlobals} from 'pageflow/testHelpers'; + +describe('ContentElementConfiguration', () => { + let testContext; + + beforeEach(() => { + const entry = factories.entry( + ScrolledEntry, + {}, + { + entryTypeSeed: normalizeSeed({ + sections: [ + {id: 1} + ], + contentElements: [ + {id: 5, permaId: 50, sectionId: 1, typeName: 'inlineVideo'}, + {id: 6, permaId: 60, sectionId: 1, typeName: 'inlineVideo'} + ] + }) + } + ); + + testContext = {entry}; + }); + + setupGlobals({ + entry: () => testContext.entry + }); + + describe('setting position to backdrop', () => { + it('sets backdropType of section to contentElement', () => { + const contentElement = testContext.entry.contentElements.get(5); + const section = testContext.entry.sections.get(1); + + contentElement.configuration.set('position', 'backdrop'); + + expect(section.configuration.get('backdropType')).toEqual('contentElement'); + }); + + it('sets backdropContentElement of section to contentElement', () => { + const contentElement = testContext.entry.contentElements.get(5); + const section = testContext.entry.sections.get(1); + + contentElement.configuration.set('position', 'backdrop'); + + expect(section.configuration.get('backdropContentElement')).toEqual( + contentElement.get('permaId') + ); + }); + + it('sets backdrop attribute of section', () => { + const contentElement = testContext.entry.contentElements.get(5); + const section = testContext.entry.sections.get(1); + + contentElement.configuration.set('position', 'backdrop'); + + expect(section.configuration.get('backdrop')).toEqual({ + contentElement: contentElement.get('permaId') + }); + }); + + it('resets position of existing backdrop content element to inline', () => { + const otherContentElement = testContext.entry.contentElements.get(6); + const contentElement = testContext.entry.contentElements.get(5); + + otherContentElement.configuration.set('position', 'backdrop'); + contentElement.configuration.set('position', 'backdrop'); + + expect(otherContentElement.configuration.get('position')).toEqual('inline'); + }); + }); + + describe('resetting position from backdrop', () => { + it('restores previous backdropType of section', () => { + const contentElement = testContext.entry.contentElements.get(5); + const section = testContext.entry.sections.get(1); + + section.configuration.set('backdropType', 'video'); + contentElement.configuration.set('position', 'backdrop'); + contentElement.configuration.set('position', 'inline'); + + expect(section.configuration.get('backdropType')).toEqual('video'); + }); + + it('restores previous backdropType even if position is set multiple times', () => { + const contentElement = testContext.entry.contentElements.get(5); + const section = testContext.entry.sections.get(1); + + section.configuration.set('backdropType', 'video'); + contentElement.configuration.set('position', 'backdrop'); + contentElement.configuration.set('position', 'backdrop'); + contentElement.configuration.set('position', 'inline'); + + expect(section.configuration.get('backdropType')).toEqual('video'); + }); + + it('restores previous backdrop attribute of section', () => { + const contentElement = testContext.entry.contentElements.get(5); + const section = testContext.entry.sections.get(1); + + section.configuration.set('backdropType', 'image'); + section.configuration.set('backdropImage', 1); + contentElement.configuration.set('position', 'backdrop'); + contentElement.configuration.set('position', 'inline'); + + expect(section.configuration.get('backdrop')).toEqual({image: 1}); + }); + + it('does not restores backdropType of section if keepBackdropType option is true', () => { + const contentElement = testContext.entry.contentElements.get(5); + const section = testContext.entry.sections.get(1); + + section.configuration.set('backdropType', 'video'); + contentElement.configuration.set('position', 'backdrop'); + contentElement.configuration.set('position', 'inline', {keepBackdropType: true}); + + expect(section.configuration.get('backdropType')).toEqual('contentElement'); + }); + + it('resets backdropContentElement of section', () => { + const contentElement = testContext.entry.contentElements.get(5); + const section = testContext.entry.sections.get(1); + + contentElement.configuration.set('position', 'backdrop'); + contentElement.configuration.set('position', 'inline'); + + expect(section.configuration.get('backdropContentElement')).toBeNull(); + }); + + it('resets backdropContentElement of section even with keepBackdropType', () => { + const contentElement = testContext.entry.contentElements.get(5); + const section = testContext.entry.sections.get(1); + + contentElement.configuration.set('position', 'backdrop'); + contentElement.configuration.set('position', 'inline', {keepBackdropType: true}); + + expect(section.configuration.get('backdropContentElement')).toBeNull(); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry-spec.js index 93bb80c526..a414c95427 100644 --- a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry-spec.js @@ -17,12 +17,14 @@ describe('ScrolledEntry', () => { describe('for sections', () => { beforeEach(() => { editor.contentElementTypes.register('inlineImage', { + supportedPositions: ['inline', 'wide', 'full', 'backdrop'], defaultConfig: { some: 'value' } }); - editor.contentElementTypes.register('heading', { + editor.contentElementTypes.register('panorama', { + supportedPositions: ['inline', 'wide', 'full', 'backdrop'], defaultConfig: { position: 'wide' } @@ -39,7 +41,7 @@ describe('ScrolledEntry', () => { {id: 10} ], contentElements: [ - {id: 5, sectionId: 10, position: 0}, + {id: 5, sectionId: 10, position: 0, configuration: {position: 'full'}}, {id: 6, sectionId: 10, position: 1} ] }) @@ -69,13 +71,13 @@ describe('ScrolledEntry', () => { it('uses position from default config when adding content element at end', () => { const {entry, requests} = testContext; - entry.insertContentElement({typeName: 'heading'}, + entry.insertContentElement({typeName: 'panorama'}, {at: 'endOfSection', id: 10}); expect(requests[0].url).toBe('/editor/entries/100/scrolled/sections/10/content_elements'); expect(JSON.parse(requests[0].requestBody)).toMatchObject({ content_element: { - typeName: 'heading', + typeName: 'panorama', position: 2, configuration: {position: 'wide'} } @@ -102,6 +104,195 @@ describe('ScrolledEntry', () => { expect(listener).toHaveBeenCalledWith(entry.contentElements.get(5)); }); + + it('supports adding content element in backdrop', () => { + const {entry, requests} = testContext; + + entry.insertContentElement({typeName: 'inlineImage'}, + {at: 'backdropOfSection', id: 10}); + + expect(requests[0].url).toBe('/editor/entries/100/scrolled/sections/10/content_elements/batch'); + expect(JSON.parse(requests[0].requestBody)).toMatchObject({ + content_elements: [ + {typeName: 'inlineImage', configuration: {position: 'backdrop', some: 'value'}}, + {id: 5}, + {id: 6} + ] + }); + }); + + it('sets backdropContentElement of section', () => { + const {entry} = testContext; + const section = entry.sections.first(); + + entry.insertContentElement({typeName: 'inlineImage'}, + {at: 'backdropOfSection', id: section.id}); + + testContext.server.respond( + 'PUT', '/editor/entries/100/scrolled/sections/10/content_elements/batch', + [200, {'Content-Type': 'application/json'}, JSON.stringify([ + {id: 7, permaId: 70}, {id: 5, permaId: 50}, {id: 6, permaId: 60} + ])] + ); + + expect(section.configuration.get('backdropContentElement')).toEqual(70); + }); + + it('ignores position from default config when inserting content element in backdrop', () => { + const {entry, requests} = testContext; + + entry.insertContentElement({typeName: 'panorama'}, + {at: 'backdropOfSection', id: 10}); + + expect(requests[0].url).toBe('/editor/entries/100/scrolled/sections/10/content_elements/batch'); + expect(JSON.parse(requests[0].requestBody)).toMatchObject({ + content_elements: [ + {typeName: 'panorama', configuration: {position: 'backdrop'}}, + {id: 5}, + {id: 6} + ] + }); + }); + + it('ignores position of first element when inserting content element in backdrop', () => { + const {entry, requests} = testContext; + + entry.insertContentElement({typeName: 'inlineImage'}, + {at: 'backdropOfSection', id: 10}); + + expect(requests[0].url).toBe('/editor/entries/100/scrolled/sections/10/content_elements/batch'); + expect(JSON.parse(requests[0].requestBody)).toMatchObject({ + content_elements: [ + {typeName: 'inlineImage', configuration: {position: 'backdrop'}}, + {id: 5}, + {id: 6} + ] + }); + }); + + it('triggers event on entry to select new backdrop content element', () => { + const {entry} = testContext; + const listener = jest.fn(); + entry.on('selectContentElement', listener); + + entry.insertContentElement({typeName: 'inlineImage'}, + {at: 'backdropOfSection', id: 10}); + + expect(listener).not.toHaveBeenCalled(); + + testContext.server.respond( + 'PUT', '/editor/entries/100/scrolled/sections/10/content_elements/batch', + [200, {'Content-Type': 'application/json'}, JSON.stringify([ + {id: 7, permaId: 70}, {id: 5, permaId: 50}, {id: 6, permaId: 60} + ])] + ); + + expect(listener).toHaveBeenCalledWith(entry.contentElements.get(7), expect.anything()); + }); + }); + + describe('for empty sections', () => { + beforeEach(() => { + editor.contentElementTypes.register('inlineImage', { + supportedPositions: ['inline', 'wide', 'backdrop'], + defaultConfig: { + some: 'value' + } + }); + + editor.contentElementTypes.register('panorama', { + supportedPositions: ['inline', 'wide', 'backdrop'], + defaultConfig: { + position: 'wide' + } + }); + + testContext.entry = factories.entry( + ScrolledEntry, + { + id: 100 + }, + { + entryTypeSeed: normalizeSeed({ + sections: [ + {id: 10} + ] + }) + }); + }); + + setupGlobals({ + entry: () => testContext.entry + }); + + it('supports adding content element in backdrop', () => { + const {entry, requests} = testContext; + + entry.insertContentElement({typeName: 'inlineImage'}, + {at: 'backdropOfSection', id: 10}); + + expect(requests[0].url).toBe('/editor/entries/100/scrolled/sections/10/content_elements'); + expect(JSON.parse(requests[0].requestBody)).toMatchObject({ + content_element: { + typeName: 'inlineImage', + position: 0, + configuration: {position: 'backdrop'} + } + }); + }); + + it('sets backdropContentElement of section', () => { + const {entry} = testContext; + const section = entry.sections.first(); + + entry.insertContentElement({typeName: 'inlineImage'}, + {at: 'backdropOfSection', id: section.id}); + + testContext.server.respond( + 'POST', '/editor/entries/100/scrolled/sections/10/content_elements', + [200, {'Content-Type': 'application/json'}, JSON.stringify({ + id: 5, permaId: 50 + })] + ); + + expect(section.configuration.get('backdropContentElement')).toEqual(50); + }); + + it('ignores position from default config when inserting content element in backdrop', () => { + const {entry, requests} = testContext; + + entry.insertContentElement({typeName: 'panorama'}, + {at: 'backdropOfSection', id: 10}); + + expect(requests[0].url).toBe('/editor/entries/100/scrolled/sections/10/content_elements'); + expect(JSON.parse(requests[0].requestBody)).toMatchObject({ + content_element: { + typeName: 'panorama', + position: 0, + configuration: {position: 'backdrop'} + } + }); + }); + + it('triggers event on entry to select new backdrop content element', () => { + const {entry} = testContext; + const listener = jest.fn(); + entry.on('selectContentElement', listener); + + entry.insertContentElement({typeName: 'inlineImage'}, + {at: 'backdropOfSection', id: 10}); + + expect(listener).not.toHaveBeenCalled(); + + testContext.server.respond( + 'POST', '/editor/entries/100/scrolled/sections/10/content_elements', + [200, {'Content-Type': 'application/json'}, JSON.stringify({ + id: 5, permaId: 50 + })] + ); + + expect(listener).toHaveBeenCalledWith(entry.contentElements.get(5)); + }); }); describe('for all content elements', () => { diff --git a/entry_types/scrolled/package/spec/editor/models/SectionConfiguration-spec.js b/entry_types/scrolled/package/spec/editor/models/SectionConfiguration-spec.js index a52d9909ad..7e347693df 100644 --- a/entry_types/scrolled/package/spec/editor/models/SectionConfiguration-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/SectionConfiguration-spec.js @@ -376,6 +376,19 @@ describe('SectionConfiguration', () => { }); }); + describe('set backdropContentElement', () => { + it('sets backdrop contentElement to value', () => { + const sectionConfiguration = new SectionConfiguration(); + + sectionConfiguration.set('backdropType', 'contentElement'); + sectionConfiguration.set('backdropContentElement', 5); + + expect(sectionConfiguration.attributes.backdrop).toEqual({ + contentElement: 5 + }); + }); + }); + describe('set backdropType', () => { it('restores previous settings', () => { const sectionConfiguration = new SectionConfiguration(); @@ -393,6 +406,20 @@ describe('SectionConfiguration', () => { }); }); + it('supports object signature', () => { + const sectionConfiguration = new SectionConfiguration(); + + sectionConfiguration.set('backdropType', 'image'); + sectionConfiguration.set('backdropImage', 1); + sectionConfiguration.set('backdropType', 'color'); + sectionConfiguration.set('backdropColor', '#000'); + sectionConfiguration.set({backdropType: 'image'}); + + expect(sectionConfiguration.attributes.backdrop).toEqual({ + image: 1 + }); + }); + it('restores previous motif area', () => { const sectionConfiguration = new SectionConfiguration(); const motifAreaA = {left: 1, top: 1, width: 10, height: 10}; @@ -411,6 +438,57 @@ describe('SectionConfiguration', () => { imageMotifArea: motifAreaA }); }); + + describe('backdropContentElement position', () => { + let testContext; + + beforeEach(() => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [ + { + id: 10, + configuration: {backdropType: 'image'} + } + ], + contentElements: [ + { + id: 5, + permaId: 50, + sectionId: 10 + } + ] + }) + }); + + testContext = {entry}; + }); + + setupGlobals({ + entry: () => testContext.entry + }); + + it('is reset to inline when changed from contentElement', () => { + const section = testContext.entry.sections.get(10); + const contentElement = testContext.entry.contentElements.get(5); + + contentElement.configuration.set('position', 'backdrop'); + section.configuration.set('backdropType', 'image'); + + expect(contentElement.configuration.get('position')).toEqual('inline'); + }); + + it('is set to backdrop when reset to contentElement', () => { + const section = testContext.entry.sections.get(10); + const contentElement = testContext.entry.contentElements.get(5); + + contentElement.configuration.set('position', 'backdrop'); + section.configuration.set('backdropType', 'image'); + section.configuration.set('backdropType', 'contentElement'); + + expect(contentElement.configuration.get('position')).toEqual('backdrop'); + }); + }); }); describe('FileSelectionHandler', () => { diff --git a/entry_types/scrolled/package/spec/editor/views/EditMotifAreaDialogView-spec.js b/entry_types/scrolled/package/spec/editor/views/EditMotifAreaDialogView-spec.js index c64db0489d..44d0f80d3e 100644 --- a/entry_types/scrolled/package/spec/editor/views/EditMotifAreaDialogView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EditMotifAreaDialogView-spec.js @@ -70,6 +70,58 @@ describe('EditMotifAreaDialogView', () => { }); }); + it('removes id suffix from property name', () => { + const model = new Backbone.Model(); + const file = factories.imageFile({width: 200, height: 100}); + + const view = new EditMotifAreaDialogView({ + model, + file, + propertyName: 'imageId' + }); + + const imgAreaSelect = fakeImgAreaSelect(); + + view.render(); + view.onShow(); + + imgAreaSelect.select({x1: 10.01, y1: 10.02, x2: 60, y2: 60}); + fireEvent.click(getByText(view.el, 'Save')); + + expect(model.get('imageMotifArea')).toEqual({ + left: 5, + top: 10, + width: 25, + height: 50 + }); + }); + + it('supports id property name', () => { + const model = new Backbone.Model(); + const file = factories.imageFile({width: 200, height: 100}); + + const view = new EditMotifAreaDialogView({ + model, + file, + propertyName: 'id' + }); + + const imgAreaSelect = fakeImgAreaSelect(); + + view.render(); + view.onShow(); + + imgAreaSelect.select({x1: 10.01, y1: 10.02, x2: 60, y2: 60}); + fireEvent.click(getByText(view.el, 'Save')); + + expect(model.get('motifArea')).toEqual({ + left: 5, + top: 10, + width: 25, + height: 50 + }); + }); + it('allows resetting the motif area property', () => { const model = new Backbone.Model({ imageMotifArea: { diff --git a/entry_types/scrolled/package/spec/editor/views/EditSectionTransitionView-spec.js b/entry_types/scrolled/package/spec/editor/views/EditSectionTransitionView-spec.js index c79dad0aef..cbffe41182 100644 --- a/entry_types/scrolled/package/spec/editor/views/EditSectionTransitionView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EditSectionTransitionView-spec.js @@ -70,6 +70,27 @@ describe('EditSectionTransitionView', () => { expect(getByLabelText('Scroll')).toBeEnabled(); }); + it('offers fade transtions if adjacent sections use backdrop content elements', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [ + {id: 1, configuration: {fullHeight: false, backdropType: 'contentElement'}}, + {id: 2, configuration: {fullHeight: false, backdropType: 'contentElement'}} + ] + }) + }); + const view = new EditSectionTransitionView({ + model: entry.sections.get(2), + entry + }); + + const {getByLabelText} = within(view.render().el); + + expect(getByLabelText('Fade background and contents')).toBeEnabled(); + expect(getByLabelText('Fade background only')).toBeEnabled(); + expect(getByLabelText('Scroll')).toBeEnabled(); + }); + it('restores fade variant when toggling to other transition and back to fade', async () => { const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ diff --git a/entry_types/scrolled/package/spec/editor/views/InsertContentElementDialogView-spec.js b/entry_types/scrolled/package/spec/editor/views/InsertContentElementDialogView-spec.js index 0273b6f285..69434470d0 100644 --- a/entry_types/scrolled/package/spec/editor/views/InsertContentElementDialogView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/InsertContentElementDialogView-spec.js @@ -10,12 +10,13 @@ describe('InsertContentElementDialogView', () => { useFakeTranslations({ 'pageflow_scrolled.editor.content_element_categories.basic.name': 'Basic', 'pageflow_scrolled.editor.content_elements.textBlock.name': 'Text block', - 'pageflow_scrolled.editor.content_elements.inlineImage.name': 'Inline image' + 'pageflow_scrolled.editor.content_elements.inlineImage.name': 'Image', + 'pageflow_scrolled.editor.content_elements.inlineVideo.name': 'Video' }); it('renders list of content element types', () => { const editor = factories.editorApi(); - editor.contentElementTypes.register('textBlock'); + editor.contentElementTypes.register('textBlock', {}); const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ contentElements: [ @@ -57,8 +58,35 @@ describe('InsertContentElementDialogView', () => { expect(availableCategories(view)).toContain('Basic'); }); + it('disables content element types that do backdrop position when inserting in backdrop', () => { + const editor = factories.editorApi(); + editor.contentElementTypes.register('textBlock', {supportedPositions: ['inline']}); + editor.contentElementTypes.register('inlineVideo', {supportedPositions: ['inline', 'backdrop']}); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [ + {id: 1, configuration: {}} + ] + }) + }); + const view = new InsertContentElementDialogView({ + entry, + editor, + insertOptions: {at: 'backdropOfSection', id: 1} + }); + + view.render(); + + expect(disabledTypeNames(view)).toContain('Text block'); + expect(availableTypeNames(view)).toContain('Video'); + }); + + function disabledTypeNames(view) { + return view.$el.find('li li button[disabled] span').map(function() { return $(this).text() }).get(); + } + function availableTypeNames(view) { - return view.$el.find('li li button span').map(function() { return $(this).text() }).get(); + return view.$el.find('li li button:not([disabled]) span').map(function() { return $(this).text() }).get(); } function availableCategories(view) { diff --git a/entry_types/scrolled/package/spec/editor/views/inputs/BackdropContentElementInputView-spec.js b/entry_types/scrolled/package/spec/editor/views/inputs/BackdropContentElementInputView-spec.js new file mode 100644 index 0000000000..63e65a03b9 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/inputs/BackdropContentElementInputView-spec.js @@ -0,0 +1,114 @@ +import {editor} from 'pageflow/editor'; +import { + BackdropContentElementInputView +} from 'editor/views/inputs/BackdropContentElementInputView'; +import { + InsertContentElementDialogView +} from 'editor/views/InsertContentElementDialogView'; + +import {within} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/extend-expect'; +import {useFakeTranslations} from 'pageflow/testHelpers'; +import {useEditorGlobals, useFakeXhr} from 'support'; + +describe('BackdropContentElementInputView', () => { + const {createEntry} = useEditorGlobals(); + useFakeXhr(); + + useFakeTranslations({ + 'pageflow_scrolled.editor.content_elements.inlineVideo.name': 'Video', + 'pageflow_scrolled.editor.backdrop_content_element_input.add': 'Add', + 'pageflow_scrolled.editor.backdrop_content_element_input.unset': 'Unset' + }); + + beforeEach(() => { + editor.contentElementTypes.register('inlineVideo', { + supportedPositions: ['inline', 'full', 'backdrop'] + }); + }); + + it('supports adding backdrop content element', async () => { + const showDialog = jest.spyOn(InsertContentElementDialogView, 'show'); + const entry = createEntry({ + sections: [ + {id: 1, configuration: {}} + ] + }); + + const view = new BackdropContentElementInputView({ + entry, + editor, + model: entry.sections.first().configuration, + }); + + const user = userEvent.setup(); + const {getByRole} = within(view.render().el); + await user.click(getByRole('button', {name: 'Add'})); + + expect(showDialog).toHaveBeenCalledWith({ + entry, + editor, + insertOptions: {at: 'backdropOfSection', id: 1} + }); + }); + + it('supports navigating to backdrop content element', async () => { + const navigate = jest.spyOn(editor, 'navigate').mockImplementation(() => {}); + const entry = createEntry({ + sections: [ + {id: 1, configuration: {backdropContentElement: 50}} + ], + contentElements: [ + { + id: 5, + permaId: 50, + sectionId: 1, + typeName: 'inlineVideo', + configuration: {position: 'backdrop'} + } + ] + }); + + const view = new BackdropContentElementInputView({ + entry, + editor, + model: entry.sections.first().configuration, + }); + + const user = userEvent.setup(); + const {getByRole} = within(view.render().el); + await user.click(getByRole('button', {name: 'Video'})); + + expect(navigate).toHaveBeenCalledWith('/scrolled/content_elements/5', {trigger: true}); + }); + + it('has button to reset position of backdrop content element', async () => { + const entry = createEntry({ + sections: [ + {id: 1, configuration: {backdropContentElement: 50}} + ], + contentElements: [ + { + id: 5, + permaId: 50, + sectionId: 1, + typeName: 'inlineVideo', + configuration: {position: 'backdrop'} + } + ] + }); + + const view = new BackdropContentElementInputView({ + entry, + editor, + model: entry.sections.first().configuration, + }); + + const user = userEvent.setup(); + const {getByRole} = within(view.render().el); + await user.click(getByRole('button', {name: 'Unset'})); + + expect(entry.contentElements.first().configuration.get('position')).toEqual('inline') + }); +}); diff --git a/entry_types/scrolled/package/spec/entryState/structure-spec.js b/entry_types/scrolled/package/spec/entryState/structure-spec.js index 4b476c0b80..76ccdc2b66 100644 --- a/entry_types/scrolled/package/spec/entryState/structure-spec.js +++ b/entry_types/scrolled/package/spec/entryState/structure-spec.js @@ -4,7 +4,8 @@ import { useChapter, useChapters, useSection, - useSectionContentElements, + useSectionForegroundContentElements, + useContentElement, watchCollections } from 'entryState'; @@ -231,6 +232,72 @@ describe('useSection', () => { expect(section).toMatchObject(expectedSection); }); + + it('does not mark section as fullHeight by default', () => { + const {result} = renderHookInEntry( + () => useSection({sectionPermaId: 101}), + { + seed: { + sections: [{permaId: 101}] + } + } + ); + + const section = result.current; + + expect(section.fullHeight).toBeUndefined() + }); + + it('marks sections with backdropType contentElement as fullHeight', () => { + const {result} = renderHookInEntry( + () => useSection({sectionPermaId: 101}), + { + seed: { + sections: [{permaId: 101, configuration: {backdropType: 'contentElement'}}], + } + } + ); + + const section = result.current; + + expect(section.fullHeight).toEqual(true) + }); + + it('overrides fullHeight to true for backdropType contentElement', () => { + const {result} = renderHookInEntry( + () => useSection({sectionPermaId: 101}), + { + seed: { + sections: [{ + permaId: 101, + configuration: { + fullHeight: false, + backdropType: 'contentElement' + } + }], + } + } + ); + + const section = result.current; + + expect(section.fullHeight).toEqual(true) + }); + + it('supports marking sections as fullHeight', () => { + const {result} = renderHookInEntry( + () => useSection({sectionPermaId: 101}), + { + seed: { + sections: [{permaId: 101, configuration: {fullHeight: true}}], + } + } + ); + + const section = result.current; + + expect(section.fullHeight).toEqual(true) + }); }); describe('useChapters', () => { @@ -339,50 +406,75 @@ describe('useChapter', () => { }); }); -describe('useSectionContentElements', () => { - const expectedContentElements = [ - { - id: 3, - permaId: 1003, - type: 'image', - position: 'sticky', - width: 1, - props: { - imageId: 4 +describe('useSectionForegroundContentElements', () => { + it('returns list of content elements of section', () => { + const {result} = renderHookInEntry( + () => useSectionForegroundContentElements({sectionId: 2}), + { + seed: { + chapters: chaptersSeed, + sections: sectionsSeed, + contentElements: contentElementsSeed + } } - }, - { - id: 4, - permaId: 1004, - type: 'textBlock', - position: 'inline', - width: 0, - props: { - children: 'Some more text' + ); + + const contentElements = result.current; + + expect(contentElements).toMatchObject([ + { + id: 3, + permaId: 1003, + type: 'image', + position: 'sticky', + width: 1, + props: { + imageId: 4 + } + }, + { + id: 4, + permaId: 1004, + type: 'textBlock', + position: 'inline', + width: 0, + props: { + children: 'Some more text' + } } - } - ]; + ]); + }); - it('returns list of content elements of section', () => { + it('filters out content elements with position backdrop', () => { const {result} = renderHookInEntry( - () => useSectionContentElements({sectionId: 2}), + () => useSectionForegroundContentElements({sectionId: 2}), { seed: { chapters: chaptersSeed, sections: sectionsSeed, - contentElements: contentElementsSeed + contentElements: [ + { + id: 1, + permaId: 1001, + sectionId: 2, + typeName: 'image', + configuration: { + position: 'backdrop', + } + } + ] } } ); const contentElements = result.current; - expect(contentElements).toMatchObject(expectedContentElements); + expect(contentElements).toMatchObject([]); }); it('rewrites legacy full positions to inline with width', () => { const {result} = renderHookInEntry( - () => useSectionContentElements({sectionId: 2}), + () => useSectionForegroundContentElements({sectionId: 2}), { seed: { chapters: chaptersSeed, @@ -417,7 +509,7 @@ describe('useSectionContentElements', () => { it('rewrites legacy wide positions to inline with width', () => { const {result} = renderHookInEntry( - () => useSectionContentElements({sectionId: 2}), + () => useSectionForegroundContentElements({sectionId: 2}), { seed: { chapters: chaptersSeed, @@ -452,7 +544,7 @@ describe('useSectionContentElements', () => { it('prefers configured width over width based on legacy positions', () => { const {result} = renderHookInEntry( - () => useSectionContentElements({sectionId: 2}), + () => useSectionForegroundContentElements({sectionId: 2}), { seed: { chapters: chaptersSeed, @@ -488,7 +580,7 @@ describe('useSectionContentElements', () => { it('turns floated positions into inline in left layout', () => { const {result} = renderHookInEntry( - () => useSectionContentElements({sectionId: 2, layout: 'left'}), + () => useSectionForegroundContentElements({sectionId: 2, layout: 'left'}), { seed: { chapters: chaptersSeed, @@ -539,7 +631,7 @@ describe('useSectionContentElements', () => { it('turns floated positions into inline in right layout', () => { const {result} = renderHookInEntry( - () => useSectionContentElements({sectionId: 2, layout: 'right'}), + () => useSectionForegroundContentElements({sectionId: 2, layout: 'right'}), { seed: { chapters: chaptersSeed, @@ -590,7 +682,7 @@ describe('useSectionContentElements', () => { it('turns sticky position into inline in centered layout', () => { const {result} = renderHookInEntry( - () => useSectionContentElements({sectionId: 2, layout: 'center'}), + () => useSectionForegroundContentElements({sectionId: 2, layout: 'center'}), { seed: { chapters: chaptersSeed, @@ -625,7 +717,7 @@ describe('useSectionContentElements', () => { it('does not set standAlone flag by default', () => { const {result} = renderHookInEntry( - () => useSectionContentElements({sectionId: 2, layout: 'center'}), + () => useSectionForegroundContentElements({sectionId: 2, layout: 'center'}), { seed: { chapters: chaptersSeed, @@ -657,7 +749,7 @@ describe('useSectionContentElements', () => { it('turns standAlone position into inline with standAlone flag', () => { const {result} = renderHookInEntry( - () => useSectionContentElements({sectionId: 2, layout: 'center'}), + () => useSectionForegroundContentElements({sectionId: 2, layout: 'center'}), { seed: { chapters: chaptersSeed, @@ -692,7 +784,7 @@ describe('useSectionContentElements', () => { it('clamps widths of floated elements in center layout', () => { const {result} = renderHookInEntry( - () => useSectionContentElements({sectionId: 2, layout: 'center'}), + () => useSectionForegroundContentElements({sectionId: 2, layout: 'center'}), { seed: { chapters: chaptersSeed, @@ -785,7 +877,7 @@ describe('useSectionContentElements', () => { it('clamps widths of sticky elements in two-column layout', () => { const {result} = renderHookInEntry( - () => useSectionContentElements({sectionId: 2, layout: 'right'}), + () => useSectionForegroundContentElements({sectionId: 2, layout: 'right'}), { seed: { chapters: chaptersSeed, @@ -861,3 +953,51 @@ describe('useSectionContentElements', () => { ]); }); }); + +describe('useContentElement', () => { + it('looks up content element by perma id', () => { + const {result} = renderHookInEntry( + () => useContentElement({permaId: 10}), + { + seed: { + contentElements: [ + { + id: 4, + permaId: 10, + typeName: 'inlineVideo', + configuration: {video: 6} + } + ] + } + } + ); + + const contentElement = result.current; + + expect(contentElement).toMatchObject({ + id: 4, + permaId: 10, + type: 'inlineVideo', + position: 'inline', + width: 0, + props: { + video: 6 + } + }); + }); + + it('returns undefined for unknown perma id', () => { + const {result} = renderHookInEntry( + () => useContentElement({permaId: 10}), + { + seed: { + contentElements: [] + } + } + ); + + const contentElement = result.current; + + expect(contentElement).toBeUndefined(); + }); +}); diff --git a/entry_types/scrolled/package/spec/entryState/updateContentElementConfiguration-spec.js b/entry_types/scrolled/package/spec/entryState/updateContentElementConfiguration-spec.js index c824806354..0469cec0df 100644 --- a/entry_types/scrolled/package/spec/entryState/updateContentElementConfiguration-spec.js +++ b/entry_types/scrolled/package/spec/entryState/updateContentElementConfiguration-spec.js @@ -1,11 +1,11 @@ -import {useSectionContentElements, updateContentElementConfiguration} from 'entryState'; +import {useSectionForegroundContentElements, updateContentElementConfiguration} from 'entryState'; import {renderHookInEntry} from 'support'; describe('updateContentElementConfiguration', () => { it('updates the configuration of a content element', () => { const newConfiguration = {some: {text: 'update'}}; - const {result} = renderHookInEntry(() => useSectionContentElements({sectionId: 1}), { + const {result} = renderHookInEntry(() => useSectionForegroundContentElements({sectionId: 1}), { seed: { sections: [{id: 1, permaId: 10}], contentElements: [{sectionId: 1, permaId: 50}] diff --git a/entry_types/scrolled/package/spec/frontend/FitViewport-spec.js b/entry_types/scrolled/package/spec/frontend/FitViewport-spec.js index 6563f84f62..e17e7ae07b 100644 --- a/entry_types/scrolled/package/spec/frontend/FitViewport-spec.js +++ b/entry_types/scrolled/package/spec/frontend/FitViewport-spec.js @@ -1,6 +1,7 @@ import {FitViewport} from 'pageflow-scrolled/frontend'; import {FullscreenDimensionProvider} from 'frontend/Fullscreen'; import styles from 'frontend/FitViewport.module.css'; +import fullscreenStyles from 'frontend/Fullscreen.module.css'; import React from 'react'; import {render} from '@testing-library/react'; @@ -97,11 +98,22 @@ describe('FitViewport', () => { expect(getOuter(container)).toHaveClass(styles.opaque); }); + it('support covering full height', () => { + const {container} = render( + + + + ); + + expect(getInner(container)).toBeNull(); + expect(container.querySelector(`.${fullscreenStyles.root}`)).not.toBeNull(); + }); + function getOuter(container) { - return container.querySelector('div[style]'); + return container.querySelector(`.${styles.container}`); } function getInner(container) { - return container.querySelector('div[style] div[style]'); + return container.querySelector(`.${styles.container} div[style]`); } }); diff --git a/entry_types/scrolled/package/spec/frontend/InlineFileRights-spec.js b/entry_types/scrolled/package/spec/frontend/InlineFileRights-spec.js index 1b9458c9c3..7cd080a04a 100644 --- a/entry_types/scrolled/package/spec/frontend/InlineFileRights-spec.js +++ b/entry_types/scrolled/package/spec/frontend/InlineFileRights-spec.js @@ -45,10 +45,10 @@ describe('InlineFileRights', () => { it('passes props to widget', () => { api.widgetTypes.register('inlineFileRightsWithProps', { - component: function ({children, context, playerControlsTransparent, playerControlsStandAlone}) { + component: function ({children, context, playerControlsFadedOut, playerControlsStandAlone}) { return (
- {context} {playerControlsTransparent.toString()} {playerControlsStandAlone.toString()} + {context} {playerControlsFadedOut.toString()} {playerControlsStandAlone.toString()}
) } @@ -64,7 +64,7 @@ describe('InlineFileRights', () => { const {container} = renderInEntry( , { seed: { diff --git a/entry_types/scrolled/package/spec/frontend/features/contentElementBox-spec.js b/entry_types/scrolled/package/spec/frontend/features/contentElementBox-spec.js new file mode 100644 index 0000000000..d68bc597c0 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/features/contentElementBox-spec.js @@ -0,0 +1,59 @@ +import {frontend, ContentElementBox} from 'frontend'; + +import React from 'react'; + +import {renderEntry, usePageObjects} from 'support/pageObjects'; +import '@testing-library/jest-dom/extend-expect' + +describe('content element box', () => { + usePageObjects(); + + beforeEach(() => { + frontend.contentElementTypes.register('test', { + component: function Component() { + return ( +
+ + Some content + +
+ ) + } + }); + }); + + it('renders box', () => { + const {getContentElementByTestId} = renderEntry({ + seed: { + contentElements: [{ + typeName: 'test' + }] + } + }); + + expect(getContentElementByTestId('test').containsBox()).toEqual(true); + }); + + it('does not render box for backdrop content element', () => { + const {getContentElementByTestId} = renderEntry({ + seed: { + sections: [{ + id: 1, + configuration: { + backdrop: { + contentElement: 10 + } + } + }], + contentElements: [{ + sectionId: 1, + permaId: 10, + typeName: 'test', + configuration: {position: 'backdrop'} + }] + } + }); + + expect(getContentElementByTestId('test').containsBox()).toEqual(false); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/features/contentElementFigure-spec.js b/entry_types/scrolled/package/spec/frontend/features/contentElementFigure-spec.js new file mode 100644 index 0000000000..d1b1f6c5df --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/features/contentElementFigure-spec.js @@ -0,0 +1,75 @@ +import {frontend, ContentElementFigure} from 'frontend'; + +import React from 'react'; + +import {renderEntry, usePageObjects} from 'support/pageObjects'; +import '@testing-library/jest-dom/extend-expect' + +describe('content element figure', () => { + usePageObjects(); + + beforeEach(() => { + frontend.contentElementTypes.register('test', { + component: function Component({configuration}) { + return ( +
+ + Some content + +
+ ) + } + }); + }); + + it('renders figure caption', () => { + const {getByTestId} = renderEntry({ + seed: { + contentElements: [{ + typeName: 'test', + configuration: { + caption: [ + { + type: 'paragraph', + children: [{text: 'A caption'}] + } + ] + } + }] + } + }); + + expect(getByTestId('test-component')).toHaveTextContent('A caption') + }); + + it('does not render figure caption for backdrop content element', () => { + const {getByTestId} = renderEntry({ + seed: { + sections: [{ + id: 1, + configuration: { + backdrop: { + contentElement: 10 + } + } + }], + contentElements: [{ + sectionId: 1, + permaId: 10, + typeName: 'test', + configuration: { + position: 'backdrop', + caption: [ + { + type: 'paragraph', + children: [{text: 'A caption'}] + } + ] + } + }] + } + }); + + expect(getByTestId('test-component')).not.toHaveTextContent('A caption'); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/features/contentElementMemoization-spec.js b/entry_types/scrolled/package/spec/frontend/features/contentElementMemoization-spec.js new file mode 100644 index 0000000000..bd98116062 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/features/contentElementMemoization-spec.js @@ -0,0 +1,70 @@ +import {frontend} from 'frontend'; + +import React from 'react'; + +import {renderEntry, usePageObjects} from 'support/pageObjects'; + +describe('content element memoization', () => { + usePageObjects(); + + let renderSpy; + + beforeEach(() => { + frontend.contentElementTypes.register('test', { + component: function Component() { + renderSpy(); + + return ( +
+ Some content +
+ ) + } + }); + }); + + it('does not rerender content element when entry rerenders', () => { + renderSpy = jest.fn(); + + const {rerender} = renderEntry({ + seed: { + contentElements: [{ + typeName: 'test' + }] + } + }); + + renderSpy.mockReset(); + rerender(); + + expect(renderSpy).not.toHaveBeenCalled(); + }); + + it('does not rerender backdrop content element when entry rerenders', () => { + renderSpy = jest.fn(); + + const {rerender} = renderEntry({ + seed: { + sections: [{ + id: 1, + configuration: { + backdrop: {contentElement: 11} + } + }], + contentElements: [{ + permaId: 11, + sectionId: 1, + typeName: 'test', + configuration: { + position: 'backdrop' + } + }] + } + }); + + renderSpy.mockReset(); + rerender(); + + expect(renderSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/features/scrollPointMessages-spec.js b/entry_types/scrolled/package/spec/frontend/features/scrollPointMessages-spec.js index 7a10fdadd4..a0a6e08d5a 100644 --- a/entry_types/scrolled/package/spec/frontend/features/scrollPointMessages-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/scrollPointMessages-spec.js @@ -1,13 +1,8 @@ -import React from 'react'; -import {frontend} from 'frontend'; - import {renderEntry, useInlineEditingPageObjects} from 'support/pageObjects'; import {fakeParentWindow} from 'support'; import {asyncHandlingOf} from 'support/asyncHandlingOf'; -jest.mock('frontend/useScrollTarget'); - describe('scroll point messages', () => { useInlineEditingPageObjects(); diff --git a/entry_types/scrolled/package/spec/frontend/features/scrollToSectionMessage-spec.js b/entry_types/scrolled/package/spec/frontend/features/scrollToSectionMessage-spec.js index f85b38fd73..d55579a273 100644 --- a/entry_types/scrolled/package/spec/frontend/features/scrollToSectionMessage-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/scrollToSectionMessage-spec.js @@ -3,30 +3,65 @@ import {fakeParentWindow} from 'support'; import {asyncHandlingOf} from 'support/asyncHandlingOf'; -import useScrollTarget from 'frontend/useScrollTarget'; -jest.mock('frontend/useScrollTarget'); - describe('SCROLL_TO_SECTION message', () => { usePageObjects(); beforeEach(() => { fakeParentWindow() + window.scrollTo = jest.fn(); + }); + + it('scrolls to section with given id', async () => { + const {fakeSectionBoundingClientRectsByPermaId} = renderEntry({ + seed: { + sections: [ + {id: 100, permaId: 10}, + {id: 101, permaId: 11} + ] + } + }); + + window.scrollY = 1000; + window.innerHeight = 1000 + fakeSectionBoundingClientRectsByPermaId({ + 10: {top: -100}, + 11: {top: 30} + }); + + await asyncHandlingOf(() => { + window.postMessage({type: 'SCROLL_TO_SECTION', payload: {id: 100}}, '*'); + }); + + expect(window.scrollTo).toHaveBeenCalledWith({ + top: -100 + 1000 - 250, + behavior: 'smooth' + }); }); - it('scrolls to scene with given index', async () => { - const {getSectionByPermaId} = renderEntry({ + it('scrolls to top section when align is start', async () => { + const {fakeSectionBoundingClientRectsByPermaId} = renderEntry({ seed: { sections: [ - {permaId: 10}, - {permaId: 11} + {id: 100, permaId: 10}, + {id: 101, permaId: 11} ] } }); + window.scrollY = 1000; + window.innerHeight = 1000 + fakeSectionBoundingClientRectsByPermaId({ + 10: {top: -100}, + 11: {top: 30} + }); + await asyncHandlingOf(() => { - window.postMessage({type: 'SCROLL_TO_SECTION', payload: {index: 1}}, '*'); + window.postMessage({type: 'SCROLL_TO_SECTION', payload: {id: 100, align: 'start'}}, '*'); }); - expect(useScrollTarget.lastTarget).toBe(getSectionByPermaId(11).el); + expect(window.scrollTo).toHaveBeenCalledWith({ + top: -100 + 1000, + behavior: 'smooth' + }); }); }); diff --git a/entry_types/scrolled/package/spec/frontend/useContentElementLifecycle-spec.js b/entry_types/scrolled/package/spec/frontend/useContentElementLifecycle-spec.js index f0cf3a1a5a..cb214a09cc 100644 --- a/entry_types/scrolled/package/spec/frontend/useContentElementLifecycle-spec.js +++ b/entry_types/scrolled/package/spec/frontend/useContentElementLifecycle-spec.js @@ -1,9 +1,11 @@ import {frontend, Entry, useContentElementLifecycle} from 'pageflow-scrolled/frontend'; import {StaticPreview} from 'frontend/useScrollPositionLifecycle'; +import {TwoColumn} from 'frontend/layouts/TwoColumn'; import {renderInEntry} from 'support'; import {simulateScrollingIntoView, simulateScrollingOutOfView} from 'support/fakeIntersectionObserver'; import {findIsActiveProbe, findIsPreparedProbe} from 'support/scrollPositionLifecycle'; +import {fakeBoundingClientRectsByTestId} from 'support/fakeBoundingClientRects'; import React from 'react'; import {act} from '@testing-library/react'; @@ -84,6 +86,62 @@ describe('useContentElementLifecycle', () => { expect(getByTestId('testElement')).toHaveTextContent('paused'); }); + + it('stays false when parent section becomes active', async () => { + const {getByTestId} = renderInEntry(, { + seed: { + contentElements: [{typeName: 'test'}] + } + }); + + act(() => + simulateScrollingIntoView(findIsActiveProbe(getByTestId('testElement').closest('section'))) + ); + + expect(getByTestId('testElement')).toHaveTextContent('paused'); + }); + + it('is true for backdrop content element when parent section becomes active', async () => { + const {getByTestId} = renderInEntry(, { + seed: { + sections: [{id: 1, configuration: {backdrop: {contentElement: 10}}}], + contentElements: [ + {permaId: 10, sectionId: 1, typeName: 'test', configuration: {position: 'backdrop'}} + ] + } + }); + + act(() => + simulateScrollingIntoView(findIsActiveProbe(getByTestId('testElement').closest('section'))) + ); + + expect(getByTestId('testElement')).toHaveTextContent('playing'); + }); + + it('is false for backdrop content element when section content intersects', async () => { + TwoColumn.contentAreaProbeProps = {'data-testid': 'contentAreaProbe'}; + + fakeBoundingClientRectsByTestId({ + testElement: {top: 300, height: 200, bottom: 500}, + contentAreaProbe: {top: 400} + }); + + const {getByTestId} = renderInEntry(, { + seed: { + sections: [{id: 1, configuration: {backdrop: {contentElement: 10}}}], + contentElements: [ + {permaId: 10, sectionId: 1, typeName: 'test', configuration: {position: 'backdrop'}}, + {permaId: 11, sectionId: 1} + ] + } + }); + + act(() => + simulateScrollingIntoView(findIsActiveProbe(getByTestId('testElement').closest('section'))) + ); + + expect(getByTestId('testElement')).toHaveTextContent('paused'); + }); }); describe('shouldLoad', () => { @@ -125,6 +183,62 @@ describe('useContentElementLifecycle', () => { expect(getByTestId('testElement')).toHaveTextContent('loaded'); }); + + it('stays false when parent section becomes active', async () => { + const {getByTestId} = renderInEntry(, { + seed: { + contentElements: [{typeName: 'test'}] + } + }); + + act(() => + simulateScrollingIntoView(findIsActiveProbe(getByTestId('testElement').closest('section'))) + ); + + expect(getByTestId('testElement')).toHaveTextContent('blank'); + }); + + it('is true for backdrop content element when parent section becomes active', async () => { + const {getByTestId} = renderInEntry(, { + seed: { + sections: [{id: 1, configuration: {backdrop: {contentElement: 10}}}], + contentElements: [ + {permaId: 10, sectionId: 1, typeName: 'test', configuration: {position: 'backdrop'}} + ] + } + }); + + act(() => + simulateScrollingIntoView(findIsActiveProbe(getByTestId('testElement').closest('section'))) + ); + + expect(getByTestId('testElement')).toHaveTextContent('loaded'); + }); + + it('is true for backdrop content element even when section content intersects', async () => { + TwoColumn.contentAreaProbeProps = {'data-testid': 'contentAreaProbe'}; + + fakeBoundingClientRectsByTestId({ + testElement: {top: 300, height: 200, bottom: 500}, + contentAreaProbe: {top: 400} + }); + + const {getByTestId} = renderInEntry(, { + seed: { + sections: [{id: 1, configuration: {backdrop: {contentElement: 10}}}], + contentElements: [ + {permaId: 10, sectionId: 1, typeName: 'test', configuration: {position: 'backdrop'}}, + {permaId: 11, sectionId: 1} + ] + } + }); + + act(() => + simulateScrollingIntoView(findIsActiveProbe(getByTestId('testElement').closest('section'))) + ); + + expect(getByTestId('testElement')).toHaveTextContent('loaded'); + }); }); describe('shouldPrepare', () => { @@ -166,6 +280,62 @@ describe('useContentElementLifecycle', () => { expect(getByTestId('testElement')).toHaveTextContent('prepared'); }); + + it('stays false when parent section becomes active', async () => { + const {getByTestId} = renderInEntry(, { + seed: { + contentElements: [{typeName: 'test'}] + } + }); + + act(() => + simulateScrollingIntoView(findIsActiveProbe(getByTestId('testElement').closest('section'))) + ); + + expect(getByTestId('testElement')).toHaveTextContent('blank'); + }); + + it('is true for backdrop content element when parent section becomes active', async () => { + const {getByTestId} = renderInEntry(, { + seed: { + sections: [{id: 1, configuration: {backdrop: {contentElement: 10}}}], + contentElements: [ + {permaId: 10, sectionId: 1, typeName: 'test', configuration: {position: 'backdrop'}} + ] + } + }); + + act(() => + simulateScrollingIntoView(findIsActiveProbe(getByTestId('testElement').closest('section'))) + ); + + expect(getByTestId('testElement')).toHaveTextContent('prepared'); + }); + + it('is true for backdrop content element even when section content intersects', async () => { + TwoColumn.contentAreaProbeProps = {'data-testid': 'contentAreaProbe'}; + + fakeBoundingClientRectsByTestId({ + testElement: {top: 300, height: 200, bottom: 500}, + contentAreaProbe: {top: 400} + }); + + const {getByTestId} = renderInEntry(, { + seed: { + sections: [{id: 1, configuration: {backdrop: {contentElement: 10}}}], + contentElements: [ + {permaId: 10, sectionId: 1, typeName: 'test', configuration: {position: 'backdrop'}}, + {permaId: 11, sectionId: 1} + ] + } + }); + + act(() => + simulateScrollingIntoView(findIsActiveProbe(getByTestId('testElement').closest('section'))) + ); + + expect(getByTestId('testElement')).toHaveTextContent('prepared'); + }); }); describe('onActivate option', () => { diff --git a/entry_types/scrolled/package/spec/frontend/v1/Backdrop-spec.js b/entry_types/scrolled/package/spec/frontend/v1/Backdrop-spec.js index 0dc48173aa..62ea8cb8f7 100644 --- a/entry_types/scrolled/package/spec/frontend/v1/Backdrop-spec.js +++ b/entry_types/scrolled/package/spec/frontend/v1/Backdrop-spec.js @@ -4,6 +4,7 @@ import '@testing-library/jest-dom/extend-expect' import {renderInEntryWithSectionLifecycle} from 'support'; import {useFakeMedia, fakeMediaRenderQueries} from 'support/fakeMedia'; +import {frontend} from 'pageflow-scrolled/frontend'; import {Backdrop} from 'frontend/v1/Backdrop'; import {useBackdrop} from 'frontend/v1/useBackdrop'; import styles from 'frontend/Backdrop.module.css'; @@ -514,6 +515,88 @@ describe('Backdrop', () => { }); }); + describe('with content element', () => { + beforeAll(() => { + frontend.contentElementTypes.register('test', { + component: function Probe({contentElementId, sectionProps}) { + return ( +
+ {sectionProps.isIntersecting ? 'Intersecting' : ''} + Container height: {sectionProps.containerDimension.height} +
+ ); + } + }); + }); + + it('renders content element passing isIntersecting and containerDimension', () => { + const {queryByTestId} = + renderInEntryWithSectionLifecycle( + () => , + { + seed: { + sections: [{id: 1}], + contentElements: [ + { + id: 10, + sectionId: 1, + permaId: 100, + typeName: 'test', + configuration: {position: 'backdrop'} + } + ] + } + } + ); + + expect(queryByTestId('contentElement-10')).toHaveTextContent('Intersecting'); + expect(queryByTestId('contentElement-10')).toHaveTextContent('Container height: 0'); + }); + + it('renders nothing if content element has been deleted', () => { + const {container} = + renderInEntryWithSectionLifecycle( + () => , + { + seed: { + contentElements: [] + } + } + ); + + expect(container.textContent).toEqual(''); + }); + + it('renders nothing if content element references element in other section', () => { + const {container} = + renderInEntryWithSectionLifecycle( + () => , + { + seed: { + sections: [ + {id: 1}, + {id: 2} + ], + contentElements: [ + { + id: 10, + permaId: 100, + sectionId: 2, + typeName: 'test', + configuration: {position: 'backdrop'} + } + ] + } + } + ); + + expect(container.textContent).toEqual(''); + }); + }); + it('hides element to unload compositor layer by default', () => { const {container} = renderInEntryWithSectionLifecycle( diff --git a/entry_types/scrolled/package/spec/frontend/v1/useMotifAreaState-spec.js b/entry_types/scrolled/package/spec/frontend/v1/useMotifAreaState-spec.js index af31026821..b31e73e7f1 100644 --- a/entry_types/scrolled/package/spec/frontend/v1/useMotifAreaState-spec.js +++ b/entry_types/scrolled/package/spec/frontend/v1/useMotifAreaState-spec.js @@ -12,9 +12,11 @@ describe('useMotifAreaState', () => { empty, transitions, updateOnScrollAndResize, - exposeMotifArea + exposeMotifArea, + backdropContentElement = false }) { const {result} = renderHook(() => useMotifAreaState({ + backdropContentElement, fullHeight, empty, transitions, @@ -30,16 +32,19 @@ describe('useMotifAreaState', () => { viewportLeft: contentArea.left, ...contentArea, }); - const motifAreaEl = createElementWithDimension({ + const motifAreaEl = motifArea && createElementWithDimension({ offsetLeft: motifArea.left, viewportLeft: motifArea.left, ...motifArea }); - const [, setMotifAreaRect, setContentAreaRef] = result.current; + const [, setMotifAreaRef, setContentAreaRef] = result.current; act(() => { - setMotifAreaRect(motifAreaEl); + if (motifAreaEl) { + setMotifAreaRef(motifAreaEl); + } + setContentAreaRef(contentAreaEl); }); @@ -110,6 +115,20 @@ describe('useMotifAreaState', () => { expect(isContentPadded).toEqual(false); }); }); + + describe('with backdrop content element', () => { + it('is always true', () => { + const [{isContentPadded}] = getMotifAreaState({ + backdropContentElement: true, + contentArea: {left: 100, width: 300}, + empty: true, + transitions: ['scrollIn', 'scrollOut'], + exposeMotifArea: false + }).current; + + expect(isContentPadded).toEqual(true); + }); + }); }); describe('paddingTop', () => { @@ -191,6 +210,54 @@ describe('useMotifAreaState', () => { }); }); }); + + describe('with backdrop content element', () => { + it('is based on viewport height', () => { + const [{paddingTop}] = getMotifAreaState({ + backdropContentElement: true, + contentArea: {left: 0, width: 400}, + transitions: ['scrollIn', 'scrollOut'], + exposeMotifArea: true + }).current; + + expect(paddingTop).toEqual('110vh'); + }); + + it('uses shorter height for fadeIn transition', () => { + const [{paddingTop}] = getMotifAreaState({ + backdropContentElement: true, + contentArea: {left: 0, width: 400}, + transitions: ['fadeIn', 'scrollOut'], + exposeMotifArea: true + }).current; + + expect(paddingTop).toEqual('70vh'); + }); + + it('uses shorter height for empty fadeOut transition', () => { + const [{paddingTop}] = getMotifAreaState({ + backdropContentElement: true, + contentArea: {left: 0, width: 400}, + transitions: ['scrollIn', 'fadeOut'], + exposeMotifArea: true, + empty: true + }).current; + + expect(paddingTop).toEqual('70vh'); + }); + + it('uses normal height for non-empty fadeOut transition', () => { + const [{paddingTop}] = getMotifAreaState({ + backdropContentElement: true, + contentArea: {left: 0, width: 400}, + transitions: ['scrollIn', 'fadeOut'], + exposeMotifArea: true, + empty: false + }).current; + + expect(paddingTop).toEqual('110vh'); + }); + }); }); describe('minHeight', () => { @@ -390,5 +457,89 @@ describe('useMotifAreaState', () => { expect(intersectionRatioY).toEqual(0); }); }); + + describe('with backdrop content element', () => { + it('becomes positive if section has content', () => { + const [{intersectionRatioY}] = getMotifAreaState({ + backdropContentElement: true, + contentArea: {left: 100, viewportTop: 400, width: 300}, + motifArea: {left: 200, viewportTop: 100, width: 500, height: 400}, + empty: false, + transitions: ['scrollIn', 'scrollOut'], + exposeMotifArea: true + }).current; + + expect(intersectionRatioY).toEqual(0.25); + }); + + it('stays 0 if does not section have content', () => { + const [{intersectionRatioY}] = getMotifAreaState({ + backdropContentElement: true, + contentArea: {left: 100, viewportTop: 400, width: 300}, + motifArea: {left: 200, viewportTop: 100, width: 500, height: 400}, + empty: true, + transitions: ['scrollIn', 'scrollOut'], + exposeMotifArea: true + }).current; + + expect(intersectionRatioY).toEqual(0); + }); + }); + }); + + describe('isMotifIntersected', () => { + describe('with backdrop content element', () => { + it('is false if section content does not intersecte motif area', () => { + const [{isMotifIntersected}] = getMotifAreaState({ + backdropContentElement: true, + contentArea: {left: 100, viewportTop: 600, width: 300}, + motifArea: {left: 200, viewportTop: 100, width: 500, height: 400}, + empty: false, + transitions: ['scrollIn', 'scrollOut'], + exposeMotifArea: true + }).current; + + expect(isMotifIntersected).toEqual(false); + }); + + it('is true if section content intersectes motif area', () => { + const [{isMotifIntersected}] = getMotifAreaState({ + backdropContentElement: true, + contentArea: {left: 100, viewportTop: 400, width: 300}, + motifArea: {left: 200, viewportTop: 100, width: 500, height: 400}, + empty: false, + transitions: ['scrollIn', 'scrollOut'], + exposeMotifArea: true + }).current; + + expect(isMotifIntersected).toEqual(true); + }); + + it('is false when empty section gets scrolled out', () => { + const [{isMotifIntersected}] = getMotifAreaState({ + backdropContentElement: true, + contentArea: {left: 100, viewportTop: 400, width: 300}, + motifArea: {left: 200, viewportTop: 100, width: 500, height: 400}, + empty: true, + transitions: ['scrollIn', 'scrollOut'], + exposeMotifArea: true + }).current; + + expect(isMotifIntersected).toEqual(false); + }); + + it('is true when empty section get scrolled out with fadeOutBg ', () => { + const [{isMotifIntersected}] = getMotifAreaState({ + backdropContentElement: true, + contentArea: {left: 100, viewportTop: 400, width: 300}, + motifArea: {left: 200, viewportTop: 100, width: 500, height: 400}, + empty: true, + transitions: ['scrollIn', 'fadeOutBg'], + exposeMotifArea: true + }).current; + + expect(isMotifIntersected).toEqual(true); + }); + }); }); }); diff --git a/entry_types/scrolled/package/spec/support/fakeBoundingClientRects.js b/entry_types/scrolled/package/spec/support/fakeBoundingClientRects.js new file mode 100644 index 0000000000..cd6df7b269 --- /dev/null +++ b/entry_types/scrolled/package/spec/support/fakeBoundingClientRects.js @@ -0,0 +1,16 @@ +export function fakeBoundingClientRectsByTestId(rectsByTestId) { + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function() { + const testId = this.getAttribute('data-testid') || + this.querySelector('[data-testid]')?.getAttribute('data-testid'); + + return { + top: 0, + left: 0, + width: 0, + height: 0, + bottom: 0, + right: 0, + ...(testId ? rectsByTestId[testId] : {}) + }; + }); +} diff --git a/entry_types/scrolled/package/spec/support/pageObjects.js b/entry_types/scrolled/package/spec/support/pageObjects.js index c7fab9dc30..0e2e830552 100644 --- a/entry_types/scrolled/package/spec/support/pageObjects.js +++ b/entry_types/scrolled/package/spec/support/pageObjects.js @@ -3,6 +3,7 @@ import React from 'react'; import {renderInEntry} from './index'; import {Entry} from 'frontend/Entry'; import foregroundStyles from 'frontend/Foreground.module.css'; +import contentElementBoxStyles from 'frontend/ContentElementBox.module.css'; import contentElementMarginStyles from 'frontend/ContentElementMargin.module.css'; import contentElementScrollSpaceStyles from 'frontend/ContentElementScrollSpace.module.css'; import {StaticPreview} from 'frontend/useScrollPositionLifecycle'; @@ -14,12 +15,19 @@ import {useFakeTranslations} from 'pageflow/testHelpers'; import {simulateScrollingIntoView} from './fakeIntersectionObserver'; export function renderEntry({seed, consent, isStaticPreview} = {}) { - return renderInEntry(, { + const result = renderInEntry(, { seed, consent, wrapper: isStaticPreview ? StaticPreview : null, queries: {...queries, ...pageObjectQueries} }); + + return { + ...result, + rerender() { + result.rerender(); + } + } } export function useInlineEditingPageObjects() { @@ -85,6 +93,23 @@ const pageObjectQueries = { return createContentElementPageObject(el); }, + fakeSectionBoundingClientRectsByPermaId(container, rectsByPermaId) { + jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function() { + const idAttribute = this.getAttribute('id'); + const permaId = idAttribute?.split('-')[1]; + + return { + top: 0, + left: 0, + width: 0, + height: 0, + bottom: 0, + right: 0, + ...(permaId ? rectsByPermaId[permaId] : {}) + }; + }); + }, + fakeContentElementBoundingClientRectsByTestId(container, rectsByTestId) { jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function() { const testIdAttribute = this.querySelector('[data-testid]')?.getAttribute('data-testid'); @@ -143,6 +168,10 @@ function createContentElementPageObject(el) { const selectionRect = el.closest('[aria-label="Select content element"]'); return { + containsBox() { + return !!el.querySelector(`.${contentElementBoxStyles.wrapper}`); + }, + hasMargin() { return !!el.closest(`.${contentElementMarginStyles.wrapper}`); }, diff --git a/entry_types/scrolled/package/spec/support/stories.js b/entry_types/scrolled/package/spec/support/stories.js index 84f63c76d7..120dd82c2e 100644 --- a/entry_types/scrolled/package/spec/support/stories.js +++ b/entry_types/scrolled/package/spec/support/stories.js @@ -181,9 +181,10 @@ function variantsExampleStories({typeName, baseConfiguration, variants}) { typeName, name: 'Variants', examples: variants.map(({ - name, configuration, themeOptions, sectionConfiguration + name, permaId, configuration, themeOptions, sectionConfiguration }) => ({ name: name, + permaId, contentElementConfiguration: {...baseConfiguration, ...configuration}, themeOptions, sectionConfiguration @@ -308,6 +309,7 @@ function exampleStoryGroup({ exampleHeading({sectionId: index, text: `${name} - ${example.name}`}), { id: 1000 + index, + permaId: example.permaId || (2000 + index), sectionId: index, typeName, configuration: example.contentElementConfiguration diff --git a/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/BeforeAfter.js b/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/BeforeAfter.js index e068515f08..22f14c3596 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/BeforeAfter.js +++ b/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/BeforeAfter.js @@ -64,7 +64,8 @@ export function BeforeAfter(configuration) { ]; return ( - + diff --git a/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/editor.js b/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/editor.js index db80f64e38..322e95dffd 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/editor.js +++ b/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/editor.js @@ -7,7 +7,7 @@ import pictogram from './pictogram.svg'; editor.contentElementTypes.register('inlineBeforeAfter', { pictogram, category: 'interactive', - supportedPositions: ['inline', 'sticky', 'standAlone', 'left', 'right'], + supportedPositions: ['inline', 'sticky', 'standAlone', 'left', 'right', 'backdrop'], supportedWidthRange: ['xxs', 'full'], configurationEditor() { diff --git a/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/stories.js b/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/stories.js index 8cf3041c50..d2e839c3ef 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/stories.js +++ b/entry_types/scrolled/package/src/contentElements/inlineBeforeAfter/stories.js @@ -53,6 +53,19 @@ storiesOfContentElement(module, { caption: 'Some text here' } }, + { + name: 'With position backdrop', + sectionConfiguration: { + backdropType: 'contentElement', + backdrop: { + contentElement: 1001 + } + }, + permaId: 1001, + configuration: { + position: 'backdrop' + } + } ], inlineFileRights: true }); diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js b/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js index d960e9b56c..92d0f7ef5d 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/InlineVideo.js @@ -10,6 +10,7 @@ import { InlineFileRights, FitViewport, PlayerEventContextDataProvider, + useBackgroundFile, useContentElementEditorState, useFileWithInlineRights, usePlayerState, @@ -25,7 +26,7 @@ import { getPlayerClickHandler } from './handlers'; -export function InlineVideo({contentElementId, configuration}) { +export function InlineVideo({contentElementId, configuration, sectionProps}) { const videoFile = useFileWithInlineRights({ configuration, collectionName: 'videoFiles', @@ -56,17 +57,22 @@ export function InlineVideo({contentElementId, configuration}) { return ( ); } else { return ( ) } @@ -75,26 +81,34 @@ export function InlineVideo({contentElementId, configuration}) { function OrientationAwareInlineVideo({ landscapeVideoFile, portraitVideoFile, landscapePosterImageFile, portraitPosterImageFile, - contentElementId, configuration + landscapeMotifArea, portraitMotifArea, + contentElementId, configuration, + sectionProps }) { const portraitOrientation = usePortraitOrientation(); const videoFile = portraitOrientation && portraitVideoFile ? portraitVideoFile : landscapeVideoFile; + const motifArea = portraitOrientation && portraitVideoFile ? + portraitMotifArea : landscapeMotifArea; + const posterImageFile = portraitOrientation && portraitPosterImageFile ? portraitPosterImageFile : landscapePosterImageFile; return ( ); } function OrientationUnawareInlineVideo({ videoFile, posterImageFile, - contentElementId, configuration + contentElementId, configuration, + sectionProps, motifArea }) { const [playerState, playerActions] = usePlayerState(); const inlineFileRightsItems = [ @@ -102,11 +116,16 @@ function OrientationUnawareInlineVideo({ {label: 'poster', file: posterImageFile} ]; + const Player = sectionProps?.containerDimension && motifArea ? + CropPositionComputingPlayer : + PlayerWithControlBar; + return ( @@ -115,7 +134,9 @@ function OrientationUnawareInlineVideo({ playerState.shouldPlay && !configuration.keepMuted} /> + ); +} + +function PlayerWithControlBar({ videoFile, posterImageFile, inlineFileRightsItems, playerState, playerActions, - contentElementId, configuration + contentElementId, configuration, + sectionProps }) { const {isEditable, isSelected} = useContentElementEditorState(); @@ -183,6 +218,8 @@ function Player({ return ( { + this.set('hidden', inputModel.get('position') !== 'backdrop'); + } + + this.listenTo(inputModel, `change:position`, update); + update(); + + this.selected = () => { + EditMotifAreaDialogView.show({ + model: inputModel, + propertyName, + file + }); + } + } +}) + editor.contentElementTypes.register('inlineVideo', { pictogram, category: 'media', - supportedPositions: ['inline', 'sticky', 'standAlone', 'left', 'right'], + supportedPositions: ['inline', 'sticky', 'standAlone', 'left', 'right', 'backdrop'], supportedWidthRange: ['xxs', 'full'], configurationEditor() { @@ -19,7 +47,7 @@ editor.contentElementTypes.register('inlineVideo', { fileSelectionHandler: 'contentElementConfiguration', positioning: false, defaultTextTrackFilePropertyName: 'defaultTextTrackFileId', - dropDownMenuItems: [InlineFileRightsMenuItem] + dropDownMenuItems: [EditMotifAreaMenuItem, InlineFileRightsMenuItem] }); this.input('posterId', FileInputView, { collection: 'image_files', @@ -33,7 +61,7 @@ editor.contentElementTypes.register('inlineVideo', { fileSelectionHandler: 'contentElementConfiguration', positioning: false, defaultTextTrackFilePropertyName: 'defaultTextTrackFileId', - dropDownMenuItems: [InlineFileRightsMenuItem] + dropDownMenuItems: [EditMotifAreaMenuItem, InlineFileRightsMenuItem] }); this.input('portraitPosterId', FileInputView, { collection: 'image_files', diff --git a/entry_types/scrolled/package/src/contentElements/inlineVideo/stories.js b/entry_types/scrolled/package/src/contentElements/inlineVideo/stories.js index b7df3eea1c..b30a95561e 100644 --- a/entry_types/scrolled/package/src/contentElements/inlineVideo/stories.js +++ b/entry_types/scrolled/package/src/contentElements/inlineVideo/stories.js @@ -14,6 +14,19 @@ storiesOfContentElement(module, { configuration: { posterId: filePermaId('imageFiles', 'turtle') } + }, + { + name: 'with position backdrop', + sectionConfiguration: { + backdropType: 'contentElement', + backdrop: { + contentElement: 1001 + } + }, + permaId: 1001, + configuration: { + position: 'backdrop' + } } ], inlineFileRights: true diff --git a/entry_types/scrolled/package/src/contentElements/vrImage/VrImage.js b/entry_types/scrolled/package/src/contentElements/vrImage/VrImage.js index a187ed55d3..e900db6896 100644 --- a/entry_types/scrolled/package/src/contentElements/vrImage/VrImage.js +++ b/entry_types/scrolled/package/src/contentElements/vrImage/VrImage.js @@ -26,7 +26,8 @@ export function VrImage({configuration, contentElementWidth}) { return (
+ aspectRatio={contentElementWidth === contentElementWidths.full ? 0.5 : 0.75} + fill={configuration.position === 'backdrop'}> diff --git a/entry_types/scrolled/package/src/contentElements/vrImage/editor.js b/entry_types/scrolled/package/src/contentElements/vrImage/editor.js index f5ef7144b5..9b24ca962c 100644 --- a/entry_types/scrolled/package/src/contentElements/vrImage/editor.js +++ b/entry_types/scrolled/package/src/contentElements/vrImage/editor.js @@ -6,7 +6,7 @@ import pictogram from './pictogram.svg'; editor.contentElementTypes.register('vrImage', { pictogram, category: 'interactive', - supportedPositions: ['inline', 'sticky', 'standAlone', 'left', 'right'], + supportedPositions: ['inline', 'sticky', 'standAlone', 'left', 'right', 'backdrop'], supportedWidthRange: ['xxs', 'full'], configurationEditor() { diff --git a/entry_types/scrolled/package/src/contentElements/vrImage/stories.js b/entry_types/scrolled/package/src/contentElements/vrImage/stories.js index 1e6b52ffa8..e4f04ed1e2 100644 --- a/entry_types/scrolled/package/src/contentElements/vrImage/stories.js +++ b/entry_types/scrolled/package/src/contentElements/vrImage/stories.js @@ -35,6 +35,19 @@ storiesOfContentElement(module, { caption: 'Some text here' } }, + { + name: 'With position backdrop', + sectionConfiguration: { + backdropType: 'contentElement', + backdrop: { + contentElement: 1001 + } + }, + permaId: 1001, + configuration: { + position: 'backdrop' + } + } ], inlineFileRights: true }); diff --git a/entry_types/scrolled/package/src/editor/api/ContentElementTypeRegistry.js b/entry_types/scrolled/package/src/editor/api/ContentElementTypeRegistry.js index e3d26947ae..2e09c3c5e2 100644 --- a/entry_types/scrolled/package/src/editor/api/ContentElementTypeRegistry.js +++ b/entry_types/scrolled/package/src/editor/api/ContentElementTypeRegistry.js @@ -70,7 +70,13 @@ export class ContentElementTypeRegistry { throw new Error(`Unknown content element type ${typeName}`); } - return this.contentElementTypes[typeName]; + return { + ...this.contentElementTypes[typeName], + displayName: I18n.t(`pageflow_scrolled.editor.content_elements.${typeName}.name`), + description: I18n.t( + `pageflow_scrolled.editor.content_elements.${typeName}.description` + ) + }; } groupedByCategory() { @@ -102,12 +108,8 @@ export class ContentElementTypeRegistry { return Object .keys(this.contentElementTypes) .map(typeName => ({ - ...this.contentElementTypes[typeName], - typeName, - displayName: I18n.t(`pageflow_scrolled.editor.content_elements.${typeName}.name`), - description: I18n.t( - `pageflow_scrolled.editor.content_elements.${typeName}.description` - ) + ...this.findByTypeName(typeName), + typeName })) .filter(contentElement => !contentElement.featureName || this.features.isEnabled(contentElement.featureName) diff --git a/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js b/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js index b015d86b9d..be750bd389 100644 --- a/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js +++ b/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js @@ -30,11 +30,12 @@ export const PreviewMessageController = Object.extend({ } }); - this.listenTo(this.entry, 'scrollToSection', section => + this.listenTo(this.entry, 'scrollToSection', (section, options) => postMessage({ type: 'SCROLL_TO_SECTION', payload: { - index: this.entry.sections.indexOf(section) + id: section.id, + ...options } }) ); diff --git a/entry_types/scrolled/package/src/editor/index.js b/entry_types/scrolled/package/src/editor/index.js index c50a8e9ced..e8fb9effda 100644 --- a/entry_types/scrolled/package/src/editor/index.js +++ b/entry_types/scrolled/package/src/editor/index.js @@ -11,6 +11,7 @@ export {editor} from './api'; export {default as buttonStyles} from './views/buttons.module.css'; export {NoOptionsHintView} from './views/NoOptionsHintView'; +export {EditMotifAreaDialogView} from './views/EditMotifAreaDialogView'; export {InlineFileRightsMenuItem} from './models/InlineFileRightsMenuItem'; Object.assign(pageflow, globalInterop); diff --git a/entry_types/scrolled/package/src/editor/models/ContentElement.js b/entry_types/scrolled/package/src/editor/models/ContentElement.js index 0a6ead2cfb..5c8f4bc061 100644 --- a/entry_types/scrolled/package/src/editor/models/ContentElement.js +++ b/entry_types/scrolled/package/src/editor/models/ContentElement.js @@ -8,6 +8,9 @@ import { delayedDestroying } from 'pageflow/editor'; +import {features} from 'pageflow/frontend'; +import {ContentElementConfiguration} from './ContentElementConfiguration'; + const widths = { xxs: -3, xs: -2, @@ -24,7 +27,8 @@ export const ContentElement = Backbone.Model.extend({ mixins: [ configurationContainer({ autoSave: true, - includeAttributesInJSON: ['position', 'typeName'] + includeAttributesInJSON: ['position', 'typeName'], + configurationModel: ContentElementConfiguration }), delayedDestroying, entryTypeEditorControllerUrls.forModel({resources: 'content_elements'}), @@ -71,9 +75,12 @@ export const ContentElement = Backbone.Model.extend({ const defaultPosition = sibling?.getPosition(); const supportedPositions = this.getType().supportedPositions || []; - if (defaultPosition && - defaultPosition !== 'inline' && - supportedPositions.includes(defaultPosition)) { + if (this.configuration.has('position')) { + delete defaultConfig.position; + } + else if (defaultPosition && + defaultPosition !== 'inline' && + supportedPositions.includes(defaultPosition)) { defaultConfig.position = defaultPosition; } @@ -91,10 +98,11 @@ export const ContentElement = Backbone.Model.extend({ getAvailablePositions() { const layout = this.section.configuration.get('layout'); + const backdrop = features.isEnabled('backdrop_content_elements') ? 'backdrop' : null; const supportedByLayout = layout === 'center' || layout === 'centerRagged' ? - ['inline', 'left', 'right', 'standAlone'] : - ['inline', 'sticky', 'standAlone']; + ['inline', 'left', 'right', 'standAlone', backdrop] : + ['inline', 'sticky', 'standAlone', backdrop]; const supportedByType = this.getType().supportedPositions; if (supportedByType) { @@ -122,7 +130,10 @@ export const ContentElement = Backbone.Model.extend({ }, clampWidthByPosition(width) { - if (['sticky', 'left', 'right'].includes(this.getResolvedPosition())) { + if (this.getPosition() === 'backdrop') { + return 0; + } + else if (['sticky', 'left', 'right'].includes(this.getResolvedPosition())) { return Math.min(Math.max(width, -2), 2); } else { diff --git a/entry_types/scrolled/package/src/editor/models/ContentElementConfiguration.js b/entry_types/scrolled/package/src/editor/models/ContentElementConfiguration.js new file mode 100644 index 0000000000..ce48f6ecea --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/ContentElementConfiguration.js @@ -0,0 +1,39 @@ +import {Configuration} from 'pageflow/editor'; + +export const ContentElementConfiguration = Configuration.extend({ + defaults: {}, + + set(name, value, options) { + const previousValue = this.get('position'); + + Configuration.prototype.set.apply(this, arguments); + + if (name === 'position' && previousValue !== value) { + const contentElement = this.parent; + const section = contentElement.section; + const currentBackdropContentElement = section.getBackdropContentElement(); + + if (value === 'backdrop') { + if (currentBackdropContentElement && + currentBackdropContentElement !== contentElement) { + currentBackdropContentElement.configuration.set('position', 'inline'); + } + + section.configuration.set({ + previousBackdropType: section.configuration.get('backdropType'), + backdropContentElement: contentElement.get('permaId'), + backdropType: 'contentElement' + }); + } + else if (currentBackdropContentElement === contentElement && + section.configuration.get('backdropType') === 'contentElement') { + section.configuration.set({ + backdropContentElement: null, + backdropType: options?.keepBackdropType ? + 'contentElement' : + section.configuration.get('previousBackdropType') + }); + } + } + } +}); diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js index 6dbfdb9898..7853aec4d2 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -45,7 +45,28 @@ export const ScrolledEntry = Entry.extend({ }, insertContentElement(attributes, {id, at, splitPoint}) { - if (at === 'endOfSection') { + if (at === 'backdropOfSection') { + const section = this.sections.get(id) + + const contentElement = this.insertContentElement( + { + ...attributes, + configuration: { + position: 'backdrop' + } + }, + (section.contentElements.length > 0) ? + {id: section.contentElements.first(), at: 'before'} : + {id, at: 'endOfSection'} + ); + + contentElement.once('change:id', () => { + section.configuration.set('backdropContentElement', contentElement.get('permaId')); + }); + + return contentElement; + } + else if (at === 'endOfSection') { const contentElement = new ContentElement({ position: this.contentElements.length, ...attributes @@ -58,12 +79,14 @@ export const ScrolledEntry = Entry.extend({ contentElement.once('sync', () => { this.trigger('selectContentElement', contentElement); }); + + return contentElement; } else { - insertContentElement(this, - this.contentElements.get(id), - attributes, - {at, splitPoint}); + return insertContentElement(this, + this.contentElements.get(id), + attributes, + {at, splitPoint}); } }, diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/insertContentElement.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/insertContentElement.js index 123f6f5e37..5d6e6ef262 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/insertContentElement.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/insertContentElement.js @@ -34,5 +34,7 @@ export function insertContentElement(entry, sibling, attributes, {at, splitPoint range: targetRange }); } - }) + }); + + return contentElement; } diff --git a/entry_types/scrolled/package/src/editor/models/Section.js b/entry_types/scrolled/package/src/editor/models/Section.js index 652ee4514d..89c5a6de4d 100644 --- a/entry_types/scrolled/package/src/editor/models/Section.js +++ b/entry_types/scrolled/package/src/editor/models/Section.js @@ -62,5 +62,11 @@ export const Section = Backbone.Model.extend({ else { return 'scroll'; } + }, + + getBackdropContentElement() { + return this.contentElements.findWhere({ + permaId: this.configuration.get('backdropContentElement') + }); } }); diff --git a/entry_types/scrolled/package/src/editor/models/SectionConfiguration.js b/entry_types/scrolled/package/src/editor/models/SectionConfiguration.js index 8c8330afb3..17f1151575 100644 --- a/entry_types/scrolled/package/src/editor/models/SectionConfiguration.js +++ b/entry_types/scrolled/package/src/editor/models/SectionConfiguration.js @@ -49,18 +49,38 @@ export const SectionConfiguration = Configuration.extend({ }, set: function(name, value) { - if (name !== 'backdrop' && name.startsWith && name.startsWith('backdrop')) { + let attrs; + + if (typeof name === 'object') { + attrs = name; + } + else { + attrs = {[name]: value}; + } + + if (!attrs.backdrop && Object.keys(attrs).some(key => key.startsWith('backdrop'))) { Configuration.prototype.set.call(this, { backdrop: this.getBackdropAttribute({ ...this.attributes, - [name]: value + ...attrs }), - [name]: value + ...attrs }); } else { Configuration.prototype.set.apply(this, arguments); } + + if (attrs.backdropType) { + const backdropContentElement = this.parent?.getBackdropContentElement(); + + if (backdropContentElement) { + backdropContentElement.configuration.set( + 'position', + attrs.backdropType === 'contentElement' ? 'backdrop' : 'inline' + ); + } + } }, getBackdropAttribute(nextAttributes) { @@ -78,6 +98,10 @@ export const SectionConfiguration = Configuration.extend({ videoMobileMotifArea: nextAttributes.backdropVideoMobileMotifArea, videoMobileInlineRightsHidden: nextAttributes.backdropVideoMobileInlineRightsHidden }; + case 'contentElement': + return { + contentElement: nextAttributes.backdropContentElement + }; default: return { image: nextAttributes.backdropImage, diff --git a/entry_types/scrolled/package/src/editor/views/EditMotifAreaDialogView.js b/entry_types/scrolled/package/src/editor/views/EditMotifAreaDialogView.js index 43a1c72f60..e503bc25d1 100644 --- a/entry_types/scrolled/package/src/editor/views/EditMotifAreaDialogView.js +++ b/entry_types/scrolled/package/src/editor/views/EditMotifAreaDialogView.js @@ -90,7 +90,9 @@ export const EditMotifAreaDialogView = Marionette.ItemView.extend({ }, getPropertyName() { - return `${this.options.propertyName}MotifArea`; + return this.options.propertyName === 'id' ? + 'motifArea' : + `${this.options.propertyName.replace(/Id$/, '')}MotifArea`; }, getMotifAreaWithRoundedValues() { diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionTransitionView.js b/entry_types/scrolled/package/src/editor/views/EditSectionTransitionView.js index f28ba3d7d5..be9dc8b2ea 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionTransitionView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionTransitionView.js @@ -1,6 +1,7 @@ import {EditConfigurationView} from 'pageflow/editor'; import {EditSectionTransitionEffectView} from './EditSectionTransitionEffectView'; import {getAvailableTransitionNames} from 'pageflow-scrolled/frontend'; +import {normalizeSectionConfigurationData} from '../../entryState'; export const EditSectionTransitionView = EditConfigurationView.extend({ translationKeyPrefix: 'pageflow_scrolled.editor.edit_section_transition', @@ -11,8 +12,8 @@ export const EditSectionTransitionView = EditConfigurationView.extend({ const previousSection = entry.sections.at(sectionIndex - 1); const availableTransitions = getAvailableTransitionNames( - this.model.configuration.attributes, - previousSection.configuration.attributes + normalizeSectionConfigurationData(this.model.configuration.attributes), + normalizeSectionConfigurationData(previousSection.configuration.attributes) ); configurationEditor.tab('transition', function() { diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionView.js b/entry_types/scrolled/package/src/editor/views/EditSectionView.js index 3aafb2ee8c..91635bb0c4 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionView.js @@ -5,9 +5,11 @@ import { SeparatorView, SliderInputView } from 'pageflow/ui'; +import {BackdropContentElementInputView} from './inputs/BackdropContentElementInputView'; import {EffectListInputView} from './inputs/EffectListInputView'; import {InlineFileRightsMenuItem} from '../models/InlineFileRightsMenuItem' import I18n from 'i18n-js'; +import {features} from 'pageflow/frontend'; import {EditMotifAreaDialogView} from './EditMotifAreaDialogView'; @@ -16,10 +18,11 @@ export const EditSectionView = EditConfigurationView.extend({ configure: function(configurationEditor) { const entry = this.options.entry; + const editor = this.options.editor; const editMotifAreaMenuItem = { name: 'editMotifArea', - label: I18n.t('pageflow_scrolled.editor.edit_section.edit_motif_area'), + label: I18n.t('pageflow_scrolled.editor.edit_motif_area_menu_item'), selected({inputModel, propertyName, file}) { EditMotifAreaDialogView.show({ @@ -31,11 +34,18 @@ export const EditSectionView = EditConfigurationView.extend({ }; configurationEditor.tab('section', function() { - this.input('fullHeight', CheckBoxInputView); - this.input('backdropType', SelectInputView, { - values: ['image', 'video', 'color'], + values: features.isEnabled('backdrop_content_elements') ? + ['image', 'video', 'color', 'contentElement'] : + ['image', 'video', 'color'], + }); + + this.input('fullHeight', CheckBoxInputView, { + disabledBinding: 'backdropType', + disabled: backdropType => backdropType === 'contentElement', + displayCheckedIfDisabled: true }); + this.input('backdropImage', FileInputView, { collection: 'image_files', fileSelectionHandler: 'sectionConfiguration', @@ -93,6 +103,13 @@ export const EditSectionView = EditConfigurationView.extend({ visibleBindingValue: 'color' }); + this.input('backdropContentElement', BackdropContentElementInputView, { + editor, + entry, + visibleBinding: 'backdropType', + visibleBindingValue: 'contentElement' + }); + this.view(SeparatorView); this.input('layout', SelectInputView, { @@ -111,7 +128,7 @@ export const EditSectionView = EditConfigurationView.extend({ displayUncheckedIfDisabled: true, visibleBinding: ['backdropType'], visible: ([backdropType]) => { - return backdropType !== 'color'; + return backdropType !== 'color' && backdropType !== 'contentElement'; }, disabledBinding: motifAreaDisabledBinding, disabled: motifAreaDisabled, @@ -128,9 +145,9 @@ export const EditSectionView = EditConfigurationView.extend({ return backdropType !== 'color' && (!appearance || appearance === 'shadow'); }, - disabledBinding: ['exposeMotifArea', ...motifAreaDisabledBinding], - disabled: ([exposeMotifArea, ...motifAreaDisabledBindingValues]) => - !exposeMotifArea || motifAreaDisabled(motifAreaDisabledBindingValues) + disabledBinding: ['backdropType', 'exposeMotifArea', ...motifAreaDisabledBinding], + disabled: ([backdropType, exposeMotifArea, ...motifAreaDisabledBindingValues]) => + (!exposeMotifArea || motifAreaDisabled(motifAreaDisabledBindingValues)) && backdropType !== 'contentElement' }); this.view(SeparatorView); diff --git a/entry_types/scrolled/package/src/editor/views/InsertContentElementDialogView.js b/entry_types/scrolled/package/src/editor/views/InsertContentElementDialogView.js index 51e31679da..62f5b4537e 100644 --- a/entry_types/scrolled/package/src/editor/views/InsertContentElementDialogView.js +++ b/entry_types/scrolled/package/src/editor/views/InsertContentElementDialogView.js @@ -83,8 +83,8 @@ const ItemView = Marionette.ItemView.extend({ tagName: 'li', className: styles.item, - template: ({displayName, description, pictogram}) => ` - + + +
+ `, + + ui: cssModulesUtils.ui(styles, 'typePictogram', 'navigate', 'name'), + + events: cssModulesUtils.events(styles, { + 'click add': function() { + InsertContentElementDialogView.show({ + entry: this.options.entry, + editor: this.options.editor, + insertOptions: {at: 'backdropOfSection', id: this.model.parent.id} + }); + this.options.entry.trigger('scrollToSection', this.model.parent, {align: 'start'}); + }, + + 'click navigate': function() { + this.options.editor.navigate( + `/scrolled/content_elements/${this.contentElement.id}`, + {trigger: true} + ); + this.options.entry.trigger('selectContentElement', this.contentElement); + this.options.entry.trigger('scrollToSection', this.model.parent, {align: 'start'}); + }, + + 'click unset': function() { + this.contentElement.configuration.set('position', 'inline', {keepBackdropType: true}); + this.update(); + } + }), + + onRender() { + this.update(); + }, + + update() { + this.contentElement = this.model.parent.getBackdropContentElement(); + + this.$el.toggleClass(styles.present, !!this.contentElement); + + if (this.contentElement) { + this.ui.name.text(this.contentElement.getType().displayName); + this.ui.typePictogram.attr('src', this.contentElement.getType().pictogram); + } + } +}); diff --git a/entry_types/scrolled/package/src/editor/views/inputs/BackdropContentElementInputView.module.css b/entry_types/scrolled/package/src/editor/views/inputs/BackdropContentElementInputView.module.css new file mode 100644 index 0000000000..f6f94016c1 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/BackdropContentElementInputView.module.css @@ -0,0 +1,75 @@ +.view {} + +.navigate, +.unset, +.present .add { + display: none; +} + +.present .navigate, +.present .unset { + display: flex; +} + +.add { + composes: plusCircled from '../icons.module.css'; + display: flex; + justify-content: center; + gap: space(2); + width: 100%; + padding: space(6) space(3); +} + +.container { + border: solid 1px var(--ui-on-surface-color-lighter); + border-radius: rounded(); + display: flex; +} + +.add, +.navigate, +.unset { + background-color: transparent; + border: 0; + text-align: left; + cursor: pointer; +} + +.navigate { + flex: 1; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M8 6l4 4 -4 4'/%3e%3c/svg%3e"); + background-position: right space(2) center; + background-repeat: no-repeat; + background-size: 1.5em 1.5em; + color-adjust: exact; +} + +.unset { + composes: cancelCircled from '../icons.module.css'; + padding: 4px space(3) 0; + border-left: solid 1px var(--ui-on-surface-color-lightest); + margin: space(2) 0; + align-items: center; + color: var(--ui-on-surface-color-light); +} + +.unset:hover, +.unset:active, +.unset:focus { + color: var(--ui-on-surface-color); +} + +.typePictogram {} + +.typePictogramBg { + background-color: var(--ui-on-surface-color-light-solid); + width: 60px; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.name { + padding: space(6) space(10) space(6) space(3); +} diff --git a/entry_types/scrolled/package/src/editor/views/inputs/PositionSelectInputView.module.css b/entry_types/scrolled/package/src/editor/views/inputs/PositionSelectInputView.module.css index ee4968fb49..3e02b35601 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/PositionSelectInputView.module.css +++ b/entry_types/scrolled/package/src/editor/views/inputs/PositionSelectInputView.module.css @@ -14,6 +14,14 @@ margin-right: auto; } +.backdropPosition { + background-color: var(--ui-selection-color); +} + +.backdropPosition .section { + padding-top: 40%; +} + .content { width: 45%; } @@ -107,6 +115,10 @@ top: 10%; } +.backdropPosition .block { + display: none; +} + .outer { position: relative; } diff --git a/entry_types/scrolled/package/src/entryState/index.js b/entry_types/scrolled/package/src/entryState/index.js index 145a0237e2..153a6bdc11 100644 --- a/entry_types/scrolled/package/src/entryState/index.js +++ b/entry_types/scrolled/package/src/entryState/index.js @@ -4,12 +4,14 @@ export {useShareProviders, useShareUrl} from './sharing'; export {useEntryTranslations} from './entryTranslations'; export {useEntryMetadata} from './metadata'; export { + normalizeSectionConfigurationData, useEntryStructure, useSectionsWithChapter, useSection, useChapters, useChapter, - useSectionContentElements + useSectionForegroundContentElements, + useContentElement } from './structure'; export {useFile} from './useFile'; export {useFileWithInlineRights} from './useFileWithInlineRights'; diff --git a/entry_types/scrolled/package/src/entryState/structure.js b/entry_types/scrolled/package/src/entryState/structure.js index cac9a05cd9..a4d01ee06b 100644 --- a/entry_types/scrolled/package/src/entryState/structure.js +++ b/entry_types/scrolled/package/src/entryState/structure.js @@ -139,28 +139,51 @@ function sectionData(section) { permaId: section.permaId, id: section.id, chapterId: section.chapterId, - ...section.configuration + ...normalizeSectionConfigurationData(section.configuration) }; } -export function useSectionContentElements({sectionId, layout}) { - const filterBySectionId = useCallback(contentElement => contentElement.sectionId === sectionId, - [sectionId]) - const contentElements = useEntryStateCollectionItems('contentElements', filterBySectionId); - - return contentElements.map(contentElement => { - const position = getPosition(contentElement, layout); - - return { - id: contentElement.id, - permaId: contentElement.permaId, - type: contentElement.typeName, - position, - width: getWidth(contentElement, position), - standAlone: contentElement.configuration.position === 'standAlone', - props: contentElement.configuration - }; - }); +export function normalizeSectionConfigurationData(configuration) { + return { + ...configuration, + ...(configuration.backdropType === 'contentElement' ? {fullHeight: true} : {}) + }; +} + +export function useSectionForegroundContentElements({sectionId, layout}) { + const filter = useCallback(contentElement => ( + contentElement.sectionId === sectionId && + contentElement.configuration.position !== 'backdrop' + ), [sectionId]); + const contentElements = useEntryStateCollectionItems('contentElements', filter); + + return contentElements.map(contentElement => + contentElementData(contentElement, layout) + ); +} + +export function useContentElement({permaId, layout}) { + const contentElement = useEntryStateCollectionItem('contentElements', permaId); + + return useMemo( + () => contentElement && contentElementData(contentElement, layout), + [contentElement, layout] + ); +} + +function contentElementData(contentElement, layout) { + const position = getPosition(contentElement, layout); + + return { + id: contentElement.id, + permaId: contentElement.permaId, + sectionId: contentElement.sectionId, + type: contentElement.typeName, + position, + width: getWidth(contentElement, position), + standAlone: contentElement.configuration.position === 'standAlone', + props: contentElement.configuration + }; } const supportedPositions = { @@ -168,6 +191,7 @@ const supportedPositions = { centerRagged: ['inline', 'left', 'right'], left: ['inline', 'sticky'], right: ['inline', 'sticky'], + backdrop: ['backdrop'] }; function getPosition(contentElement, layout) { diff --git a/entry_types/scrolled/package/src/frontend/Chapter.js b/entry_types/scrolled/package/src/frontend/Chapter.js index 7961967a4e..b7d9dd9e5e 100644 --- a/entry_types/scrolled/package/src/frontend/Chapter.js +++ b/entry_types/scrolled/package/src/frontend/Chapter.js @@ -8,28 +8,22 @@ export default function Chapter(props) {
{renderSections(props.sections, props.currentSectionIndex, - props.setCurrentSection, - props.scrollTargetSectionIndex, - props.setScrollTargetSectionIndex)} + props.setCurrentSection)}
); } function renderSections(sections, currentSectionIndex, - setCurrentSection, - scrollTargetSectionIndex, - setScrollTargetSectionIndex) { + setCurrentSection) { function onActivate(section) { setCurrentSection(section); - setScrollTargetSectionIndex(null); } return sections.map((section) => { return (
currentSectionIndex ? 'below' : section.sectionIndex < currentSectionIndex ? 'above' : 'active'} - isScrollTarget={section.sectionIndex === scrollTargetSectionIndex} onActivate={() => onActivate(section)} section={section} /> diff --git a/entry_types/scrolled/package/src/frontend/Content.js b/entry_types/scrolled/package/src/frontend/Content.js index 28ce591204..efb14d5302 100644 --- a/entry_types/scrolled/package/src/frontend/Content.js +++ b/entry_types/scrolled/package/src/frontend/Content.js @@ -1,7 +1,6 @@ -import React, {useState, useCallback} from 'react'; +import React, {useCallback} from 'react'; import Chapter from "./Chapter"; -import ScrollToSectionContext from './ScrollToSectionContext'; import {VhFix} from './VhFix'; import {useCurrentSectionIndexState} from './useCurrentChapter'; import {useEntryStructure} from '../entryState'; @@ -9,6 +8,7 @@ import {withInlineEditingDecorator} from './inlineEditing'; import {usePostMessageListener} from './usePostMessageListener'; import {useSectionChangeEvents} from './useSectionChangeEvents'; import {sectionChangeMessagePoster} from './sectionChangeMessagePoster'; +import {useScrollToTarget} from './useScrollTarget'; import { AtmoProvider } from './useAtmo'; @@ -17,8 +17,6 @@ import styles from './Content.module.css'; export const Content = withInlineEditingDecorator('ContentDecorator', function Content(props) { const [currentSectionIndex, setCurrentSectionIndexState] = useCurrentSectionIndexState(); - const [scrollTargetSectionIndex, setScrollTargetSectionIndex] = useState(null); - const entryStructure = useEntryStructure(); useSectionChangeEvents(currentSectionIndex); @@ -39,33 +37,23 @@ export const Content = withInlineEditingDecorator('ContentDecorator', function C updateChapterSlug(section); }, [setCurrentSectionIndexState]); + const scrollToTarget = useScrollToTarget(); + const receiveMessage = useCallback(data => { if (data.type === 'SCROLL_TO_SECTION') { - setScrollTargetSectionIndex(data.payload.index) + scrollToTarget({id: data.payload.id, align: data.payload.align}); } - }, []); + }, [scrollToTarget]); usePostMessageListener(receiveMessage); - function scrollToSection(index) { - if (index === 'next') { - index = currentSectionIndex + 1; - } - - setScrollTargetSectionIndex(index); - } - return (
- - {renderChapters(entryStructure, - currentSectionIndex, - setCurrentSection, - scrollTargetSectionIndex, - setScrollTargetSectionIndex)} - + {renderChapters(entryStructure, + currentSectionIndex, + setCurrentSection)}
diff --git a/entry_types/scrolled/package/src/frontend/ContentElement.js b/entry_types/scrolled/package/src/frontend/ContentElement.js index 8788e30f91..42709c5791 100644 --- a/entry_types/scrolled/package/src/frontend/ContentElement.js +++ b/entry_types/scrolled/package/src/frontend/ContentElement.js @@ -9,15 +9,16 @@ import {ContentElementErrorBoundary} from './ContentElementErrorBoundary'; import styles from './ContentElement.module.css'; -export const ContentElement = withInlineEditingDecorator( +export const ContentElement = React.memo(withInlineEditingDecorator( 'ContentElementDecorator', function ContentElement(props) { const Component = api.contentElementTypes.getComponent(props.type); if (Component) { return ( - - + + Element of unknown type "{props.type}" } } -); +), arePropsEqual); + +function arePropsEqual(prevProps, nextProps) { + return ( + prevProps.id === nextProps.id && + prevProps.permaId === nextProps.permaId && + prevProps.type === nextProps.type && + prevProps.position === nextProps.position && + prevProps.width === nextProps.width && + prevProps.itemProps === nextProps.itemProps && + prevProps.customMargin === nextProps.customMargin && + prevProps.sectionProps === nextProps.sectionProps && + prevProps.lifecycleOverride === nextProps.lifecycleOverride + ); +} ContentElement.defaultProps = { itemProps: {} diff --git a/entry_types/scrolled/package/src/frontend/ContentElementBox.js b/entry_types/scrolled/package/src/frontend/ContentElementBox.js index f944cbd5ff..f6286b2ad6 100644 --- a/entry_types/scrolled/package/src/frontend/ContentElementBox.js +++ b/entry_types/scrolled/package/src/frontend/ContentElementBox.js @@ -1,5 +1,7 @@ import React from 'react'; +import {useContentElementAttributes} from './useContentElementAttributes'; + import styles from './ContentElementBox.module.css'; /** @@ -10,6 +12,12 @@ import styles from './ContentElementBox.module.css'; * @param {string} props.children - Content of box. */ export function ContentElementBox({children}) { + const {position} = useContentElementAttributes(); + + if (position === 'backdrop') { + return children; + } + return (
{children} diff --git a/entry_types/scrolled/package/src/frontend/ContentElementFigure.js b/entry_types/scrolled/package/src/frontend/ContentElementFigure.js index 5f08a3b8f2..6b96213b47 100644 --- a/entry_types/scrolled/package/src/frontend/ContentElementFigure.js +++ b/entry_types/scrolled/package/src/frontend/ContentElementFigure.js @@ -12,7 +12,11 @@ import {widths} from './layouts'; */ export function ContentElementFigure({configuration, children}) { const updateConfiguration = useContentElementConfigurationUpdate(); - const {width} = useContentElementAttributes(); + const {width, position} = useContentElementAttributes(); + + if (position === 'backdrop') { + return children; + } return (
+ ), index ) @@ -29,20 +29,6 @@ export function ContentElements(props) { ); } -const MemoizedContentElement = React.memo( - ContentElement, - (prevProps, nextProps) => ( - prevProps.id === nextProps.id && - prevProps.permaId === nextProps.permaId && - prevProps.type === nextProps.type && - prevProps.position === nextProps.position && - prevProps.width === nextProps.width && - prevProps.itemProps === nextProps.itemProps && - prevProps.customMargin === nextProps.customMargin && - prevProps.sectionProps === nextProps.sectionProps - ) -); - ContentElements.defaultProps = { children: (item, child) => child }; diff --git a/entry_types/scrolled/package/src/frontend/FitViewport.js b/entry_types/scrolled/package/src/frontend/FitViewport.js index 1a21bb6e08..b7ec2cc5f2 100644 --- a/entry_types/scrolled/package/src/frontend/FitViewport.js +++ b/entry_types/scrolled/package/src/frontend/FitViewport.js @@ -2,7 +2,7 @@ import React, {useContext} from 'react'; import classNames from 'classnames'; import styles from "./FitViewport.module.css"; -import {useFullscreenDimensions} from "./Fullscreen"; +import Fullscreen, {useFullscreenDimensions} from "./Fullscreen"; const AspectRatioContext = React.createContext(); @@ -30,17 +30,21 @@ const AspectRatioContext = React.createContext(); * @param {Object} [props.file] - Use width/height of file to calculate aspect ratio. * @param {number} [props.scale] - Only take up fraction of the viewport height supplied as value between 0 and 1. * @param {Object} [props.opaque] - Render black background behind content. + * @param {string} [props.fill] - Ignore aspect ration and fill viewport vertically. */ -export function FitViewport({file, aspectRatio, opaque, children, scale = 1}) { +export function FitViewport({file, aspectRatio, opaque, children, fill, scale = 1}) { const {height} = useFullscreenDimensions(); if (!file && !aspectRatio) return children; - aspectRatio = aspectRatio || (file.height / file.width); + aspectRatio = fill ? 'fill' : aspectRatio || (file.height / file.width); let maxWidthCSS; - if (height) { + if (fill) { + maxWidthCSS = null; + } + else if (height) { // thumbnail view/fixed size: calculate absolute width in px maxWidthCSS = (height / aspectRatio * scale) + 'px'; } else { @@ -60,7 +64,15 @@ export function FitViewport({file, aspectRatio, opaque, children, scale = 1}) { FitViewport.Content = function FitViewportContent({children}) { - const arPaddingTop = useContext(AspectRatioContext) * 100; + let arPaddingTop = useContext(AspectRatioContext); + + if (arPaddingTop === 'fill') { + return ( + + ) + } + + arPaddingTop = arPaddingTop * 100 if (!arPaddingTop) { return children; diff --git a/entry_types/scrolled/package/src/frontend/Foreground.module.css b/entry_types/scrolled/package/src/frontend/Foreground.module.css index 5807d7f3a6..27d7de8eae 100644 --- a/entry_types/scrolled/package/src/frontend/Foreground.module.css +++ b/entry_types/scrolled/package/src/frontend/Foreground.module.css @@ -1,4 +1,6 @@ .Foreground { + pointer-events: none; + position: relative; z-index: 3; diff --git a/entry_types/scrolled/package/src/frontend/Fullscreen.module.css b/entry_types/scrolled/package/src/frontend/Fullscreen.module.css index 08c13f0ef0..fe60725e9a 100644 --- a/entry_types/scrolled/package/src/frontend/Fullscreen.module.css +++ b/entry_types/scrolled/package/src/frontend/Fullscreen.module.css @@ -2,7 +2,7 @@ width: 100%; height: calc(100 * var(--vh)); position: relative; - overflow: hidden; + overflow: clip; } @media print { diff --git a/entry_types/scrolled/package/src/frontend/InlineFileRights.js b/entry_types/scrolled/package/src/frontend/InlineFileRights.js index 77d6205314..fa50bf5301 100644 --- a/entry_types/scrolled/package/src/frontend/InlineFileRights.js +++ b/entry_types/scrolled/package/src/frontend/InlineFileRights.js @@ -8,7 +8,7 @@ import styles from './InlineFileRights.module.css'; export function InlineFileRights({items = [], context = 'standAlone', - playerControlsTransparent, + playerControlsFadedOut, playerControlsStandAlone}) { const {t} = useI18n(); const filteredItems = items.filter(item => @@ -21,7 +21,7 @@ export function InlineFileRights({items = [], return ( + props={{context, playerControlsFadedOut, playerControlsStandAlone}}>
    {filteredItems.map(({label, file}) =>
  • diff --git a/entry_types/scrolled/package/src/frontend/MediaPlayer/index.js b/entry_types/scrolled/package/src/frontend/MediaPlayer/index.js index 6b7494ed59..f6f8cbe4ef 100644 --- a/entry_types/scrolled/package/src/frontend/MediaPlayer/index.js +++ b/entry_types/scrolled/package/src/frontend/MediaPlayer/index.js @@ -1,8 +1,7 @@ -import React, {useEffect, useContext, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import classNames from 'classnames'; import PlayerContainer from './PlayerContainer'; -import ScrollToSectionContext from "../ScrollToSectionContext"; import {watchPlayer} from './watchPlayer'; import {applyPlayerState} from './applyPlayerState'; import {updatePlayerState} from './updatePlayerState'; @@ -58,13 +57,11 @@ function Poster({imageUrl, objectPosition, hide}) { function PreparedMediaPlayer(props){ let playerRef = useRef(); let previousPlayerState = useRef(props.playerState); - let scrollToSection = useContext(ScrollToSectionContext); let eventContextData = useEventContextData(); let unwatchPlayer; let onSetup = (newPlayer)=>{ playerRef.current = newPlayer; - newPlayer.on('ended', () => props.nextSectionOnEnd && scrollToSection('next')); unwatchPlayer = watchPlayer(newPlayer, props.playerActions); applyPlayerState(newPlayer, props.playerState, props.playerActions) diff --git a/entry_types/scrolled/package/src/frontend/MotifArea.module.css b/entry_types/scrolled/package/src/frontend/MotifArea.module.css index acf9e8f836..7df283ae21 100644 --- a/entry_types/scrolled/package/src/frontend/MotifArea.module.css +++ b/entry_types/scrolled/package/src/frontend/MotifArea.module.css @@ -3,6 +3,7 @@ background: radial-gradient(transparent, currentColor); z-index: 2; opacity: 0; + pointer-events: none; /* Fix Chrome z-index bug. Elements can not placed in front of video diff --git a/entry_types/scrolled/package/src/frontend/PlayerControls/ClassicPlayerControls/BigPlayPauseButton.js b/entry_types/scrolled/package/src/frontend/PlayerControls/ClassicPlayerControls/BigPlayPauseButton.js index b31f8dacf7..c57303a511 100644 --- a/entry_types/scrolled/package/src/frontend/PlayerControls/ClassicPlayerControls/BigPlayPauseButton.js +++ b/entry_types/scrolled/package/src/frontend/PlayerControls/ClassicPlayerControls/BigPlayPauseButton.js @@ -14,6 +14,8 @@ export function BigPlayPauseButton(props) { return (
    diff --git a/entry_types/scrolled/package/src/frontend/PlayerControls/ClassicPlayerControls/BigPlayPauseButton.module.css b/entry_types/scrolled/package/src/frontend/PlayerControls/ClassicPlayerControls/BigPlayPauseButton.module.css index f37e3400a4..40a1714766 100644 --- a/entry_types/scrolled/package/src/frontend/PlayerControls/ClassicPlayerControls/BigPlayPauseButton.module.css +++ b/entry_types/scrolled/package/src/frontend/PlayerControls/ClassicPlayerControls/BigPlayPauseButton.module.css @@ -7,6 +7,11 @@ display: flex; align-items: center; justify-content: center; + transition: opacity 0.2s ease; +} + +.fadeOutDelay { + transition-delay: 0.5s; } .pointerCursor { diff --git a/entry_types/scrolled/package/src/frontend/PlayerControls/ClassicPlayerControls/index.js b/entry_types/scrolled/package/src/frontend/PlayerControls/ClassicPlayerControls/index.js index 18a0a25665..3b6934be07 100644 --- a/entry_types/scrolled/package/src/frontend/PlayerControls/ClassicPlayerControls/index.js +++ b/entry_types/scrolled/package/src/frontend/PlayerControls/ClassicPlayerControls/index.js @@ -14,25 +14,27 @@ import styles from '../ControlBar.module.css'; export function ClassicPlayerControls(props) { const darkBackground = useDarkBackground(); - const transparent = (!props.standAlone && props.unplayed) || (props.isPlaying && props.inactive); + const fadedOut = (!props.standAlone && props.unplayed) || (props.isPlaying && props.inactive) || props.fadedOut; return ( -
    +
    {props.children} {!props.standAlone &&
    ); } -function renderControlBar(props, darkBackground, transparent) { +function renderControlBar(props, darkBackground, fadedOut) { return (
    ); diff --git a/entry_types/scrolled/package/src/frontend/PlayerControls/ControlBar.module.css b/entry_types/scrolled/package/src/frontend/PlayerControls/ControlBar.module.css index 44caae41a6..f7d0a78052 100644 --- a/entry_types/scrolled/package/src/frontend/PlayerControls/ControlBar.module.css +++ b/entry_types/scrolled/package/src/frontend/PlayerControls/ControlBar.module.css @@ -3,6 +3,10 @@ height: 100%; } +.sticky { + overflow: clip; +} + .lightBackground { background: rgba(255, 255, 255, 0.2); } @@ -26,9 +30,14 @@ z-index: 3; } +.sticky .inset { + position: sticky; + margin-top: -40px; +} + .inset::before, .controlBarInner { - transition: opacity 0.2s ease; + transition: opacity 0.2s ease, visibility 0.2s; } .inset::before { @@ -46,9 +55,10 @@ flex: 1; } -.transparent .controlBarInner, -.transparent.inset::before { +.fadedOut .controlBarInner, +.fadedOut.inset::before { opacity: 0; + visibility: hidden; } .button { diff --git a/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/index.js b/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/index.js index 787786a3e5..5421107d91 100644 --- a/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/index.js +++ b/entry_types/scrolled/package/src/frontend/PlayerControls/WaveformPlayerControls/index.js @@ -50,7 +50,7 @@ export function WaveformPlayerControls(props) {
    diff --git a/entry_types/scrolled/package/src/frontend/RootProviders.js b/entry_types/scrolled/package/src/frontend/RootProviders.js index bff42d72b6..716c59c65a 100644 --- a/entry_types/scrolled/package/src/frontend/RootProviders.js +++ b/entry_types/scrolled/package/src/frontend/RootProviders.js @@ -11,6 +11,7 @@ import {MediaMutedProvider} from './useMediaMuted'; import {AudioFocusProvider} from './useAudioFocus'; import {ConsentProvider} from './thirdPartyConsent'; import {CurrentSectionProvider} from './useCurrentChapter'; +import {ScrollTargetEmitterProvider} from './useScrollTarget'; export function RootProviders({seed, consent = consentApi, children}) { return ( @@ -23,7 +24,9 @@ export function RootProviders({seed, consent = consentApi, children}) { - {children} + + {children} + diff --git a/entry_types/scrolled/package/src/frontend/Section.js b/entry_types/scrolled/package/src/frontend/Section.js index 1eb52e0945..9ba0b9c76d 100644 --- a/entry_types/scrolled/package/src/frontend/Section.js +++ b/entry_types/scrolled/package/src/frontend/Section.js @@ -1,13 +1,17 @@ -import React, {useRef, useMemo} from 'react'; +import React, {useMemo} from 'react'; import classNames from 'classnames'; import { SectionAtmo } from './SectionAtmo'; -import {useSectionContentElements, useAdditionalSeedData, useFileWithInlineRights} from '../entryState'; +import { + useSectionForegroundContentElements, + useAdditionalSeedData, + useFileWithInlineRights +} from '../entryState'; import Foreground from './Foreground'; import {SectionInlineFileRights} from './SectionInlineFileRights'; import {Layout, widths as contentElementWidths} from './layouts'; -import useScrollTarget from './useScrollTarget'; +import {useScrollTarget} from './useScrollTarget'; import {SectionLifecycleProvider, useSectionLifecycle} from './useSectionLifecycle' import {SectionViewTimelineProvider} from './SectionViewTimelineProvider'; import {withInlineEditingDecorator} from './inlineEditing'; @@ -20,18 +24,14 @@ import {getTransitionStyles, getEnterAndExitTransitions} from './transitions' import {getAppearanceComponents} from './appearance'; const Section = withInlineEditingDecorator('SectionDecorator', function Section({ - section, contentElements, state, isScrollTarget, onActivate, domIdPrefix + section, transitions, backdrop, contentElements, state, onActivate, domIdPrefix }) { const { - useBackdrop, useBackdropSectionClassNames, useBackdropSectionCustomProperties } = (useAdditionalSeedData('frontendVersion') === 2 ? v2 : v1); - const backdrop = useBackdrop(section); - - const ref = useRef(); - useScrollTarget(ref, isScrollTarget); + const ref = useScrollTarget(section.id); const transitionStyles = getTransitionStyles(section, section.previousSection, section.nextSection); @@ -63,6 +63,7 @@ const Section = withInlineEditingDecorator('SectionDecorator', function Section( {(children) => @@ -164,10 +162,27 @@ function SectionContents({ } function ConnectedSection(props) { - const contentElements = useSectionContentElements({sectionId: props.section.id, - layout: props.section.layout}); + const contentElements = useSectionForegroundContentElements({ + sectionId: props.section.id, + layout: props.section.layout + }); + + const { + useBackdrop, + } = (useAdditionalSeedData('frontendVersion') === 2 ? v2 : v1); + + const backdrop = useBackdrop(props.section); + + const transitions = getEnterAndExitTransitions( + props.section, + props.section.previousSection, + props.section.nextSection + ); - return
    + return
    } export { ConnectedSection as Section }; diff --git a/entry_types/scrolled/package/src/frontend/__mocks__/useScrollTarget.js b/entry_types/scrolled/package/src/frontend/__mocks__/useScrollTarget.js deleted file mode 100644 index dee54bf591..0000000000 --- a/entry_types/scrolled/package/src/frontend/__mocks__/useScrollTarget.js +++ /dev/null @@ -1,9 +0,0 @@ -import {useEffect} from 'react'; - -export default function useScrollTarget(ref, isScrollTarget) { - useEffect(() => { - if (ref.current && isScrollTarget) { - useScrollTarget.lastTarget = ref.current; - } - }, [ref, isScrollTarget]); -} diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBox.module.css b/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBox.module.css index ae602463bb..8e9ceab794 100644 --- a/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBox.module.css +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/CardBox.module.css @@ -1,5 +1,6 @@ .content { position: relative; + pointer-events: auto; } @media print { diff --git a/entry_types/scrolled/package/src/frontend/foregroundBoxes/GradientBox.module.css b/entry_types/scrolled/package/src/frontend/foregroundBoxes/GradientBox.module.css index 3a9b9884d0..8cbe7304f1 100644 --- a/entry_types/scrolled/package/src/frontend/foregroundBoxes/GradientBox.module.css +++ b/entry_types/scrolled/package/src/frontend/foregroundBoxes/GradientBox.module.css @@ -1,8 +1,8 @@ -.wrapper { -} +.wrapper {} .content { position: relative; + pointer-events: auto; } .shadow { diff --git a/entry_types/scrolled/package/src/frontend/global.module.css b/entry_types/scrolled/package/src/frontend/global.module.css index da041da494..2c7b14c9c7 100644 --- a/entry_types/scrolled/package/src/frontend/global.module.css +++ b/entry_types/scrolled/package/src/frontend/global.module.css @@ -17,4 +17,5 @@ margin: 0; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + -webkit-tap-highlight-color: transparent; } diff --git a/entry_types/scrolled/package/src/frontend/index.js b/entry_types/scrolled/package/src/frontend/index.js index 2a4c1d6643..16436f995c 100644 --- a/entry_types/scrolled/package/src/frontend/index.js +++ b/entry_types/scrolled/package/src/frontend/index.js @@ -57,6 +57,7 @@ export * from './SectionThumbnail'; export {Entry} from './Entry'; export {useAudioFocus} from './useAudioFocus'; export {useDarkBackground} from './backgroundColor'; +export {useBackgroundFile} from './v1/useBackgroundFile'; export { useAdditionalSeedData, diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/ActionButton.js b/entry_types/scrolled/package/src/frontend/inlineEditing/ActionButton.js index acd3b07c80..a0dc1639b1 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/ActionButton.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/ActionButton.js @@ -3,19 +3,26 @@ import classNames from 'classnames'; import styles from './ActionButton.module.css'; +import background from './images/background.svg'; +import foreground from './images/foreground.svg'; import pencil from './images/pencil.svg'; const icons = { + background, + foreground, pencil }; -export function ActionButton({icon, text, position, onClick}) { +export function ActionButton({icon, text, position, onClick, size = 'md'}) { const Icon = icons[icon]; + const iconSize = size === 'md' ? 15 : 20; return ( - ); diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/ActionButton.module.css b/entry_types/scrolled/package/src/frontend/inlineEditing/ActionButton.module.css index 480611dba1..48fb474d25 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/ActionButton.module.css +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/ActionButton.module.css @@ -9,6 +9,15 @@ border-radius: 3px; font-size: 13px; box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; + cursor: pointer; +} + +.size-lg { + padding: 10px; } .button:hover, @@ -18,8 +27,6 @@ .button svg { fill: currentColor; - vertical-align: bottom; - margin-right: 8px; } .position-outside { @@ -36,3 +43,8 @@ bottom: 0.5em; right: 0.5em; } + +.position-center { + left: 50%; + transform: translateX(-50%); +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/BackdropDecorator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/BackdropDecorator.js new file mode 100644 index 0000000000..cf4b94de95 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/BackdropDecorator.js @@ -0,0 +1,67 @@ +import React from 'react'; +import classNames from 'classnames'; + +import {useEditorSelection} from './EditorState'; +import {useI18n} from '../i18n'; +import {useScrollToTarget} from '../useScrollTarget'; +import {ActionButton} from './ActionButton'; + +import styles from './BackdropDecorator.module.css'; + +export function BackdropDecorator({backdrop, motifAreaState, children}) { + const {t} = useI18n({locale: 'ui'}); + + const {isSelected: isSectionSelected, select: selectSection} = useEditorSelection({ + id: backdrop.contentElement?.sectionId, + type: 'sectionSettings' + }); + + const { + isSelected: isBackdropElementSelected, + select: selectBackdropElement + } = useEditorSelection({ + id: backdrop.contentElement?.id, + type: 'contentElement' + }); + + const scrollToTarget = useScrollToTarget(); + + let text, icon, handleClick; + + if (isBackdropElementSelected) { + text = t('pageflow_scrolled.inline_editing.back_to_section'); + icon = 'foreground'; + handleClick = () => selectSection(); + } + else if (backdrop.contentElement) { + text = t('pageflow_scrolled.inline_editing.select_backdrop_content_element'); + icon = 'background'; + + handleClick = () => { + scrollToTarget({id: backdrop.contentElement.sectionId, align: 'start'}); + selectBackdropElement(); + } + } + else { + return children; + } + + return ( + <> +
    +
    + +
    +
    + {children} + + ); +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/BackdropDecorator.module.css b/entry_types/scrolled/package/src/frontend/inlineEditing/BackdropDecorator.module.css new file mode 100644 index 0000000000..528463fad7 --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/BackdropDecorator.module.css @@ -0,0 +1,24 @@ +.wrapper { + position: absolute; + top: 0; + left: 0; + width: 100%; + pointer-events: none; + z-index: 100000; + display: none; +} + +.wrapper.visible { + display: block; +} + +.inner { + position: sticky; + top: 0; + padding-top: 28px; + margin-bottom: 50px; +} + +.inner > * { + pointer-events: auto; +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/BackgroundContentElementDecorator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/BackgroundContentElementDecorator.js new file mode 100644 index 0000000000..cf400a726e --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/BackgroundContentElementDecorator.js @@ -0,0 +1,21 @@ +import React from 'react'; + +import {useEditorSelection} from './EditorState'; + +export function BackgroundContentElementDecorator({contentElement, children}) { + const {isSelected} = useEditorSelection({ + id: contentElement?.id, + type: 'contentElement' + }); + + const {isSelected: isSectionSelected} = useEditorSelection({ + id: contentElement?.sectionId, + type: 'sectionSettings' + }); + + return ( +
    + {children} +
    + ) +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.js index 79419fca99..612a3d3885 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.js @@ -59,6 +59,7 @@ function DefaultSelectionRect(props) { drag={drag} dragHandleTitle={t('pageflow_scrolled.inline_editing.drag_content_element')} full={props.width === widths.full || props.customMargin} + inset={props.position === 'backdrop'} ariaLabel={t('pageflow_scrolled.inline_editing.select_content_element')} insertButtonTitles={t('pageflow_scrolled.inline_editing.insert_content_element')} onClick={() => select()} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js index 3ef895bf4c..6d1af3aa62 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js @@ -1,6 +1,7 @@ import React from 'react'; import classNames from 'classnames'; import styles from './SectionDecorator.module.css'; +import backdropStyles from './BackdropDecorator.module.css'; import contentElementStyles from './ContentElementDecorator.module.css'; import {Toolbar} from './Toolbar'; @@ -11,7 +12,7 @@ import {useI18n} from '../i18n'; import transitionIcon from './images/arrows.svg'; -export function SectionDecorator({section, contentElements, children}) { +export function SectionDecorator({backdrop, section, contentElements, transitions, children}) { const {t} = useI18n({locale: 'ui'}); const {isSelected, select, resetSelection} = useEditorSelection({ @@ -19,6 +20,11 @@ export function SectionDecorator({section, contentElements, children}) { type: 'sectionSettings' }); + const {isSelected: isBackdropElementSelected} = useEditorSelection({ + id: backdrop.contentElement?.id, + type: 'contentElement' + }); + const {isSelected: isHighlighted} = useEditorSelection({ id: section.id, type: 'section' @@ -43,6 +49,7 @@ export function SectionDecorator({section, contentElements, children}) { function selectIfOutsideContentItem(event) { if (!event.target.closest(`.${contentElementStyles.wrapper}`) && + !event.target.closest(`.${backdropStyles.wrapper}`) && !event.target.closest('#fullscreenRoot')) { isSelected ? resetSelection() : select(); } @@ -50,7 +57,7 @@ export function SectionDecorator({section, contentElements, children}) { return (
    {renderEditTransitionButton({id: section.previousSection && section.id, @@ -69,10 +76,12 @@ export function SectionDecorator({section, contentElements, children}) { ); } -function className(isSectionSelected, transitionSelection, isHighlighted) { +function className(isSectionSelected, transitionSelection, isHighlighted, isBackdropElementSelected, transitions) { return classNames(styles.wrapper, { [styles.selected]: isSectionSelected, [styles.highlighted]: isHighlighted, + [styles.lineAbove]: isBackdropElementSelected && transitions[0].startsWith('fade'), + [styles.lineBelow]: isBackdropElementSelected && transitions[1].startsWith('fade'), [styles.transitionSelected]: transitionSelection.isSelected }); } diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.module.css b/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.module.css index 87d6f3d57f..b0cf389e27 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.module.css +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.module.css @@ -3,23 +3,39 @@ } .highlighted > section::before, +.lineAbove > section::before, +.lineBelow > section::before, .selected > section::before { content: ""; display: block; position: absolute; - border: solid 1px currentColor; - top: 5px; - left: 5px; - right: 5px; - bottom: 5px; + top: 6px; + left: 6px; + right: 6px; + bottom: 6px; z-index: 9; pointer-events: none; } +.highlighted > section::before, +.selected > section::before { + border: solid 1px currentColor; +} + +.lineAbove > section::before, +.lineBelow > section::before, .highlighted > section::before { opacity: 0.3; } +.lineAbove > section::before { + border-top: dotted 1px currentColor; +} + +.lineBelow > section::before { + border-bottom: dotted 1px currentColor; +} + .transitionSelected > section:before { content: ""; position: absolute; diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/SelectionRect.js b/entry_types/scrolled/package/src/frontend/inlineEditing/SelectionRect.js index 82de7813b4..3ebff57790 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/SelectionRect.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/SelectionRect.js @@ -12,6 +12,7 @@ export function SelectionRect(props) { return (
    + + + + diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/images/foreground.svg b/entry_types/scrolled/package/src/frontend/inlineEditing/images/foreground.svg new file mode 100644 index 0000000000..7312b547eb --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/images/foreground.svg @@ -0,0 +1,19 @@ + + + + + + + diff --git a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js index 7ced95a9d8..a42f23e26d 100644 --- a/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js +++ b/entry_types/scrolled/package/src/frontend/layouts/TwoColumn.js @@ -15,7 +15,9 @@ export function TwoColumn(props) { return (
    -
    +
    {renderItems(props, shouldInline)} {renderPlaceholder(props.placeholder)} @@ -45,6 +47,9 @@ function useShouldInlineSticky() { // Used in tests to render markers around groups TwoColumn.GroupComponent = 'div'; +// Used to set data-testids on probe element +TwoColumn.contentAreaProbeProps = {}; + function renderItems(props, shouldInline) { return groupItemsByPosition(props.items, shouldInline).map((group, index) => ({ contentElementId: id, - width - }), [id, width]); + width, + position + }), [id, width, position]); return ( diff --git a/entry_types/scrolled/package/src/frontend/useContentElementLifecycle.js b/entry_types/scrolled/package/src/frontend/useContentElementLifecycle.js index c0c8af0a7a..7760ea7733 100644 --- a/entry_types/scrolled/package/src/frontend/useContentElementLifecycle.js +++ b/entry_types/scrolled/package/src/frontend/useContentElementLifecycle.js @@ -17,10 +17,15 @@ const useLifecycle = createScrollPositionLifecycleHook( ContentElementLifecycleContext ); -export function ContentElementLifecycleProvider({type, children}) { +export function ContentElementLifecycleProvider({type, children, override}) { const {lifecycle} = api.contentElementTypes.getOptions(type); - if (lifecycle) { + if (override) { + return ( + + ); + } + else if (lifecycle) { return ( {children} diff --git a/entry_types/scrolled/package/src/frontend/useScrollTarget.js b/entry_types/scrolled/package/src/frontend/useScrollTarget.js index 1c216dc704..97f045cce8 100644 --- a/entry_types/scrolled/package/src/frontend/useScrollTarget.js +++ b/entry_types/scrolled/package/src/frontend/useScrollTarget.js @@ -1,12 +1,46 @@ -import {useEffect} from 'react'; +import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef} from 'react'; +import BackboneEvents from 'backbone-events-standalone'; + +const ScrollTargetEmitterContext = createContext(); + +export function ScrollTargetEmitterProvider({children}) { + const emitter = useMemo(() => Object.assign({}, BackboneEvents), []); + + return ( + + {children} + + ); +} + +export function useScrollToTarget() { + const emitter = useContext(ScrollTargetEmitterContext); + + return useCallback( + ({id, align}) => emitter.trigger(id, align), + [emitter] + ) +} + +export function useScrollTarget(id) { + const ref = useRef(); + + const emitter = useContext(ScrollTargetEmitterContext); -export default function useScrollTarget(ref, isScrollTarget) { useEffect(() => { - if (ref.current && isScrollTarget) { - window.scrollTo({ - top: ref.current.getBoundingClientRect().top + window.scrollY - window.innerHeight * 0.25, - behavior: 'smooth' - }) - } - }, [ref, isScrollTarget]) + emitter.on(id, align => { + if (ref.current) { + window.scrollTo({ + top: ref.current.getBoundingClientRect().top + + window.scrollY + - (align === 'start' ? 0 : window.innerHeight * 0.25), + behavior: 'smooth' + }); + } + }); + + return () => emitter.off(id) + }, [id, emitter]); + + return ref; } diff --git a/entry_types/scrolled/package/src/frontend/v1/Backdrop/BackgroundAsset.js b/entry_types/scrolled/package/src/frontend/v1/Backdrop/BackgroundAsset.js index c7480701c5..677f391cee 100644 --- a/entry_types/scrolled/package/src/frontend/v1/Backdrop/BackgroundAsset.js +++ b/entry_types/scrolled/package/src/frontend/v1/Backdrop/BackgroundAsset.js @@ -6,6 +6,7 @@ import {useBackgroundFile} from './../useBackgroundFile'; import {BackgroundVideo} from './BackgroundVideo'; import {BackgroundImage} from './BackgroundImage'; +import {BackgroundContentElement} from './BackgroundContentElement'; export function BackgroundAsset(props) { const backgroundFile = useBackgroundFile({ @@ -15,7 +16,18 @@ export function BackgroundAsset(props) { containerDimension: props.containerDimension }); - if (props.backdrop.video) { + if (props.backdrop.contentElement) { + return ( + + + + ); + } + else if (props.backdrop.video) { return ( ({ + ...sectionLifecycle, + isActive: sectionLifecycle.isActive && !isIntersecting + }), [sectionLifecycle, isIntersecting]); + + const sectionProps = useMemo(() => ({ + isIntersecting, containerDimension + }), [isIntersecting, containerDimension]); + + return ( +
    + +
    + ); + } +); diff --git a/entry_types/scrolled/package/src/frontend/v1/Backdrop/index.js b/entry_types/scrolled/package/src/frontend/v1/Backdrop/index.js index f527f0480b..e43be3c829 100644 --- a/entry_types/scrolled/package/src/frontend/v1/Backdrop/index.js +++ b/entry_types/scrolled/package/src/frontend/v1/Backdrop/index.js @@ -1,14 +1,16 @@ import React from 'react'; import classNames from 'classnames'; +import {withInlineEditingDecorator} from '../../inlineEditing'; import useDimension from './../../useDimension'; import {useSectionLifecycle} from './../../useSectionLifecycle'; import {BackgroundAsset} from './BackgroundAsset'; import styles from '../../Backdrop.module.css'; +import sharedTransitionStyles from '../../transitions/shared.module.css'; -export function Backdrop(props) { +export const Backdrop = withInlineEditingDecorator('BackdropDecorator', function Backdrop(props) { const [containerDimension, setContainerRef] = useDimension(); const {shouldLoad} = useSectionLifecycle(); @@ -17,7 +19,9 @@ export function Backdrop(props) { {[styles.noCompositionLayer]: !shouldLoad && !props.eagerLoad}, props.transitionStyles.backdrop, props.transitionStyles[`backdrop-${props.state}`])}> -
    +
    {props.children( @@ -29,7 +33,7 @@ export function Backdrop(props) {
    ); -} +}); Backdrop.defaultProps = { children: children => children, diff --git a/entry_types/scrolled/package/src/frontend/v1/useBackdrop.js b/entry_types/scrolled/package/src/frontend/v1/useBackdrop.js index 2505c7048f..148befb6a9 100644 --- a/entry_types/scrolled/package/src/frontend/v1/useBackdrop.js +++ b/entry_types/scrolled/package/src/frontend/v1/useBackdrop.js @@ -1,4 +1,4 @@ -import {useFileWithInlineRights} from '../../entryState'; +import {useContentElement, useFileWithInlineRights} from '../../entryState'; import {usePortraitOrientation} from '../usePortraitOrientation'; export function useBackdrop(section) { @@ -12,8 +12,17 @@ export function useBackdrop(section) { collectionName: 'imageFiles', propertyName: 'image' }); + const contentElement = useContentElement({ + permaId: section.backdrop?.contentElement, + layout: 'backdrop' + }); - if (section.backdrop?.color || + if (contentElement && contentElement.sectionId === section.id) { + return { + contentElement + }; + } + else if (section.backdrop?.color || (section.backdrop?.image && section.backdrop.image.toString().startsWith('#'))) { return { color: section.backdrop.color || section.backdrop.image diff --git a/entry_types/scrolled/package/src/frontend/v1/useMotifAreaState.js b/entry_types/scrolled/package/src/frontend/v1/useMotifAreaState.js index 2547cea5c7..266f9d591a 100644 --- a/entry_types/scrolled/package/src/frontend/v1/useMotifAreaState.js +++ b/entry_types/scrolled/package/src/frontend/v1/useMotifAreaState.js @@ -13,9 +13,17 @@ import useDimension from '../useDimension'; * isContentPadded, // true if motif and content will * // not fit side by side. * + * isMotifIntersected, // true if either section content or + * // or content from the next section + * // entering with a fadeBg transition + * // overlaps the motif. Used to hide + * // interactive parts (e.g., player + * // controls) of backdrop content + * // elements. + * * intersectionRatioY, // Ratio of the motif area that is * // covered by the content given the - * // current scroll position if motif + * // current scroll position of motif * // is exposed. * * paddingTop, // Distance to shift down the content @@ -33,6 +41,8 @@ import useDimension from '../useDimension'; * ] * * @param {Object} options + * @param {boolean} backdropContentElement - Whether the section has a + * backdrop content element. * @param {string[]} transitions - Names of the section's enter and exit * transitions. * @param {boolean} fullHeight - Whether the section has full or dynamic @@ -45,7 +55,7 @@ import useDimension from '../useDimension'; * @private */ export function useMotifAreaState({ - transitions, fullHeight, empty, exposeMotifArea, updateOnScrollAndResize + backdropContentElement, transitions, fullHeight, empty, exposeMotifArea, updateOnScrollAndResize } = {}) { const [motifAreaRect, setMotifAreaRectRef] = useBoundingClientRect({updateOnScrollAndResize}); const [motifAreaDimension, setMotifAreaDimensionRef] = useDimension(); @@ -61,35 +71,55 @@ export function useMotifAreaState({ dependencies: [isPadded] }); - const isContentPadded = exposeMotifArea && - isIntersectingX(motifAreaRect, contentAreaRect) && - motifAreaRect.height > 0 && - !empty; + const contentRequiresPadding = + exposeMotifArea && + isIntersectingX(motifAreaRect, contentAreaRect) && + motifAreaRect.height > 0 && + !empty; - const paddingTop = getMotifAreaPadding(isContentPadded, transitions, motifAreaDimension); + const paddingTop = getMotifAreaPadding( + contentRequiresPadding, + transitions, + motifAreaDimension, + backdropContentElement, + empty + ); // Force measuring content area again since applying the padding // changes the intersection ratio. - const willBePadded = paddingTop > 0; + const willBePadded = !!paddingTop; useEffect(() => { setIsPadded(willBePadded); }, [willBePadded]); + const intersectionRatioY = getIntersectionRatioY(motifAreaRect, contentAreaRect); + return [ { paddingTop, - isContentPadded, + isContentPadded: contentRequiresPadding || backdropContentElement, minHeight: getMotifAreaMinHeight(fullHeight, transitions, motifAreaDimension), - intersectionRatioY: getIntersectionRatioY(isContentPadded, motifAreaRect, contentAreaRect) + intersectionRatioY: contentRequiresPadding ? intersectionRatioY : 0, + isMotifIntersected: getIsMotifIntersected(empty, transitions, intersectionRatioY) }, setMotifAreaRef, setContentAreaRef ]; } -function getMotifAreaPadding(isContentPadded, transitions, motifAreaDimension) { - if (!isContentPadded) { +function getMotifAreaPadding( + contentRequiresPadding, transitions, motifAreaDimension, backdropContentElement, empty +) { + if (backdropContentElement) { + if (transitions[0] === 'fadeIn' || (empty && transitions[1] === 'fadeOut')) { + return '70vh'; + } + else { + return '110vh'; + } + } + else if (!contentRequiresPadding) { return; } @@ -157,11 +187,20 @@ function getMotifAreaMinHeight(fullHeight, transitions, motifAreaDimension) { } } -function getIntersectionRatioY(isContentPadded, motifAreaRect, contentAreaRect) { +function getIntersectionRatioY(motifAreaRect, contentAreaRect) { const motifAreaOverlap = Math.max( 0, Math.min(motifAreaRect.height, motifAreaRect.bottom - contentAreaRect.top) ); - return isContentPadded ? motifAreaOverlap / motifAreaRect.height : 0; + return motifAreaRect.height > 0 ? motifAreaOverlap / motifAreaRect.height : 0; +} + +function getIsMotifIntersected(empty, transitions, intersectionRatioY) { + // Hide interactive parts of backdrop content elements (e.g., player + // controls) if: + // - section has content and it has been scrolled to overlap or + // - next section enters with fadeOutBg making it contents potentially + // overlap the motif area + return !empty || transitions[1] === 'fadeOutBg' ? intersectionRatioY > 0 : false } diff --git a/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.js b/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.js index 4080e2e68c..7fa7fb2ecf 100644 --- a/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.js +++ b/entry_types/scrolled/package/src/widgets/iconInlineFileRights/IconInlineFileRights.js @@ -6,7 +6,7 @@ import {ThemeIcon} from 'pageflow-scrolled/frontend'; import styles from './IconInlineFileRights.module.css'; export function IconInlineFileRights({ - context, playerControlsStandAlone, playerControlsTransparent, children + context, playerControlsStandAlone, playerControlsFadedOut, children }) { if (context === 'afterElement') { return null; @@ -14,7 +14,7 @@ export function IconInlineFileRights({ return (