diff --git a/app/assets/stylesheets/pageflow/ui/forms.scss b/app/assets/stylesheets/pageflow/ui/forms.scss index b06764b7c5..86429f0293 100644 --- a/app/assets/stylesheets/pageflow/ui/forms.scss +++ b/app/assets/stylesheets/pageflow/ui/forms.scss @@ -85,6 +85,7 @@ textarea.short { } .radio_input, +.radio_button, .check_box_input { padding: space(1) 0; position: relative; @@ -108,6 +109,10 @@ textarea.short { } } +.radio_button { + padding: space(2); +} + .radio_input { padding-top: 10px; } @@ -140,9 +145,20 @@ textarea.short { .slider_input { padding: space(1) 0 0; + label { + margin-bottom: space(2.5); + } + + .slider_wrapper { + display: flex; + align-items: center; + gap: space(2); + } + .slider { - margin: 10px 10px 0 40px; + flex: 1; border: 1px solid var(--ui-on-surface-color-lighter); + margin-right: 10px; } .ui-slider-handle { @@ -152,8 +168,7 @@ textarea.short { .value { font-size: 11px; - margin: 7px 0; - float: left; + min-width: space(8); } &.disabled .slider, @@ -227,6 +242,18 @@ textarea.short { display: none; } +.visually_hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .input-disabled { button, select, diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index c85d15de39..660406c96f 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1280,6 +1280,12 @@ de: hide: Außerhalb des Editors ausblenden show: Außerhalb des Editors einblenden hidden: Nur im Editor sichtbar + section_padding_visualization: + intersecting_auto: "Darstellung des dynamischen Abstands, der sich an die Größe des Motivbereichs anpasst, um Überlappungen von Text und Motiv zu vermeiden" + intersecting_manual: "Darstellung des manuell definierten Abstands, der bei Änderung der Fenstergröße konstant bleibt" + side_by_side: "Darstellung des oberen Abstands, wenn der Inhalt neben dem Motivbereich Platz findet" + top_padding: "Darstellung des oberen Abstands" + bottom_padding: "Darstellung des unteren Abstands" select_link_destination: cancel: Abbrechen create: Erstellen @@ -1296,6 +1302,9 @@ de: selectable_section_item: title: Abschnitt auswählen edit_motif_area_menu_item: Motivbereich markieren... + edit_motif_area_input: + select: Motivbereich auswählen + edit: Motivbereich bearbeiten backdrop_content_element_input: add: Neues Element unset: Nicht mehr als Hintergrund verwenden @@ -1348,19 +1357,32 @@ de: tabs: sectionPaddings: Innenabstände portrait: Hochkant + + side_by_side_info: 'Wenn Motiv und Inhalt nebeneinander passen:' attributes: + topPaddingVisualization: + label: Abstand oben + exposeMotifArea: + label: Abstand oben + values: + 'true': Automatisch anpassen, um das Motiv im Hintergrund freizustellen + 'false': Manuell festlegen + sideBySideVisualization: + label: Nebeneinander paddingTop: - blank: (Standard) - label: Oben + label: Abstand oben + bottomPaddingVisualization: + label: Abstand unten paddingBottom: - blank: (Standard) - label: Unten + label: Abstand unten + samePortraitPaddings: + label: Gleiche Innenabstände wie bei Querformat verwenden + portraitExposeMotifArea: + label: Abstand oben portraitPaddingTop: - blank: (Standard) - label: Oben (Hochkant) + label: Abstand oben (Hochkant) portraitPaddingBottom: - blank: (Standard) - label: Unten (Hochkant) + label: Abstand unten (Hochkant) typography_sizes: xl: Sehr groß lg: Groß diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 0068fd2261..ec8c35a5ca 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1206,6 +1206,8 @@ en: cardSurfaceColor: label: Cards background color auto: "(Auto)" + sectionPaddings: + label: "Top/bottom padding" tabs: section: Section edit_section_transition: @@ -1262,6 +1264,12 @@ en: hide: Hide outside of the editor show: Show outside of the editor hidden: Only visible in editor + section_padding_visualization: + intersecting_auto: "Visualization of dynamic padding that adjusts to the motif area size to prevent text from overlapping the motif" + intersecting_manual: "Visualization of manually defined padding that stays constant as viewport size changes" + side_by_side: "Visualization of top padding when content fits next to the motif area" + top_padding: "Visualization of top padding" + bottom_padding: "Visualization of bottom padding" select_link_destination: cancel: Cancel create: Create @@ -1278,6 +1286,9 @@ en: selectable_section_item: title: Select section edit_motif_area_menu_item: Select motif area... + edit_motif_area_input: + select: Select motif area + edit: Edit motif area backdrop_content_element_input: add: New element unset: No longer use as backdrop @@ -1324,6 +1335,37 @@ en: remove: "Remove style" crop_types: circle: Circle + section_paddings_input: + auto: (Auto) + edit_section_paddings: + tabs: + sectionPaddings: Paddings + portrait: Portrait + side_by_side_info: 'When motif and content fit side by side:' + attributes: + topPaddingVisualization: + label: Top padding + exposeMotifArea: + label: Top Padding + values: + 'true': Adjust automatically to expose a motif in the background + 'false': Set manually + sideBySideVisualization: + label: Side by side + paddingTop: + label: Top padding + bottomPaddingVisualization: + label: Bottom padding + paddingBottom: + label: Bottom padding + samePortraitPaddings: + label: Use same paddings as in landscape orientation + portraitExposeMotifArea: + label: Top Padding + portraitPaddingTop: + label: Top padding (Portrait) + portraitPaddingBottom: + label: Bottom padding (Portrait) typography_sizes: xl: Very large lg: Large 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 40f7206de0..c25efebaaf 100644 --- a/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js +++ b/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js @@ -220,6 +220,27 @@ describe('PreviewMessageController', () => { })).resolves.toMatchObject({type: 'SELECT', payload: {id: 1, type: 'sectionTransition'}}); }); + it('sends SELECT message to iframe on selectSectionPaddings event on model', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 1}] + }) + }); + const iframeWindow = createIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow}); + + await postReadyMessageAndWaitForAcknowledgement(iframeWindow); + + return expect(new Promise(resolve => { + iframeWindow.addEventListener('message', event => { + if (event.data.type === 'SELECT') { + resolve(event.data); + } + }); + entry.trigger('selectSectionPaddings', entry.sections.first()); + })).resolves.toMatchObject({type: 'SELECT', payload: {id: 1, type: 'sectionPaddings'}}); + }); + it('sends SELECT message to iframe on selectWidget event on model', async () => { const entry = factories.entry(ScrolledEntry, {}, { widgetTypes: factories.widgetTypes([{ @@ -348,6 +369,38 @@ describe('PreviewMessageController', () => { })).resolves.toBe('/widgets/header'); }); + it('navigates to paddings route on SELECTED message for sectionPaddings', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 1}] + }) + }); + const iframeWindow = createIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow, editor}); + + return expect(new Promise(resolve => { + editor.on('navigate', resolve); + window.postMessage({type: 'SELECTED', payload: {id: 1, type: 'sectionPaddings'}}, '*'); + })).resolves.toBe('/scrolled/sections/1/paddings'); + }); + + it('navigates to paddings route with position query param on SELECTED message for sectionPaddings', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 1}] + }) + }); + const iframeWindow = createIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow, editor}); + + return expect(new Promise(resolve => { + editor.on('navigate', resolve); + window.postMessage({type: 'SELECTED', payload: {id: 1, type: 'sectionPaddings', position: 'bottom'}}, '*'); + })).resolves.toBe('/scrolled/sections/1/paddings?position=bottom'); + }); + it('displays insert dialog on INSERT_CONTENT_ELEMENT message', () => { const editor = factories.editorApi(); const entry = factories.entry(ScrolledEntry, {}, { @@ -653,6 +706,105 @@ describe('PreviewMessageController', () => { window.postMessage({type: 'SAVED_SCROLL_POINT'}, '*'); })).resolves.toEqual('received'); }); + + it('sends instant SCROLL_TO_SECTION in preserveScrollPoint if paddings were selected', async () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 5}] + }) + }); + + const iframeWindow = createIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow, editor}); + + await postReadyMessageAndWaitForAcknowledgement(iframeWindow); + + // First select paddings + await new Promise(resolve => { + editor.once('navigate', resolve); + window.postMessage({type: 'SELECTED', payload: {id: 5, type: 'sectionPaddings'}}, '*'); + }); + + return expect(new Promise(resolve => { + iframeWindow.addEventListener('message', event => { + if (event.data.type === 'SCROLL_TO_SECTION') { + resolve(event.data); + } + }); + controller.preserveScrollPoint(() => {}); + })).resolves.toMatchObject({ + type: 'SCROLL_TO_SECTION', + payload: {id: 5, behavior: 'instant'} + }); + }); + + it('sends SCROLL_TO_SECTION with nearEnd align when bottom paddings were selected', async () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 5}] + }) + }); + + const iframeWindow = createIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow, editor}); + + await postReadyMessageAndWaitForAcknowledgement(iframeWindow); + + // Select bottom paddings + await new Promise(resolve => { + editor.once('navigate', resolve); + window.postMessage({type: 'SELECTED', payload: {id: 5, type: 'sectionPaddings', position: 'bottom'}}, '*'); + }); + + return expect(new Promise(resolve => { + iframeWindow.addEventListener('message', event => { + if (event.data.type === 'SCROLL_TO_SECTION') { + resolve(event.data); + } + }); + controller.preserveScrollPoint(() => {}); + })).resolves.toMatchObject({ + type: 'SCROLL_TO_SECTION', + payload: {id: 5, align: 'nearEnd', behavior: 'instant'} + }); + }); + + it('clears selected paddings when another selection type is received', async () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 5}] + }) + }); + + const iframeWindow = createIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow, editor}); + + await postReadyMessageAndWaitForAcknowledgement(iframeWindow); + + // First select paddings + await new Promise(resolve => { + editor.once('navigate', resolve); + window.postMessage({type: 'SELECTED', payload: {id: 5, type: 'sectionPaddings'}}, '*'); + }); + + // Then select section settings + await new Promise(resolve => { + editor.once('navigate', resolve); + window.postMessage({type: 'SELECTED', payload: {id: 5, type: 'sectionSettings'}}, '*'); + }); + + return expect(new Promise(resolve => { + iframeWindow.addEventListener('message', event => { + if (event.data.type === 'SAVE_SCROLL_POINT') { + resolve('save_scroll_point'); + } + }); + controller.preserveScrollPoint(() => {}); + })).resolves.toEqual('save_scroll_point'); + }); }); function postReadyMessageAndWaitForAcknowledgement(iframeWindow) { diff --git a/entry_types/scrolled/package/spec/editor/views/EditSectionPaddingsView-spec.js b/entry_types/scrolled/package/spec/editor/views/EditSectionPaddingsView-spec.js new file mode 100644 index 0000000000..764b721488 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/EditSectionPaddingsView-spec.js @@ -0,0 +1,763 @@ +import {EditSectionPaddingsView} from 'editor/views/EditSectionPaddingsView'; +import {EditMotifAreaDialogView} from 'editor/views/EditMotifAreaDialogView'; + +import {useFakeTranslations, renderBackboneView} from 'pageflow/testHelpers'; +import {useEditorGlobals, useFakeXhr} from 'support'; +import '@testing-library/jest-dom/extend-expect'; +import 'support/toBeVisibleViaBinding'; +import userEvent from '@testing-library/user-event'; + +jest.mock('editor/views/EditMotifAreaDialogView'); + +describe('EditSectionPaddingsView', () => { + useFakeXhr(); + const {createEntry} = useEditorGlobals(); + + useFakeTranslations({ + 'pageflow_scrolled.editor.section_padding_visualization.intersecting_auto': 'Auto', + 'pageflow_scrolled.editor.section_padding_visualization.intersecting_manual': 'Manual', + 'pageflow_scrolled.editor.section_padding_visualization.side_by_side': 'SideBySide', + 'pageflow_scrolled.editor.section_padding_visualization.top_padding': 'TopPadding', + 'pageflow_scrolled.editor.section_padding_visualization.bottom_padding': 'Bottom', + 'pageflow_scrolled.editor.edit_section_paddings.tabs.sectionPaddings': 'Landscape', + 'pageflow_scrolled.editor.edit_section_paddings.tabs.portrait': 'Portrait', + 'pageflow_scrolled.editor.edit_section_paddings.attributes.samePortraitPaddings.label': 'Same as landscape', + 'pageflow_scrolled.editor.edit_motif_area_input.select': 'Select motif area', + 'pageflow_scrolled.editor.edit_motif_area_input.edit': 'Edit motif area', + 'pageflow_scrolled.editor.edit_section_paddings.attributes.exposeMotifArea.values.true': 'Auto mode', + 'pageflow_scrolled.editor.edit_section_paddings.attributes.exposeMotifArea.values.false': 'Manual mode' + }); + + it('shows auto visualization when exposeMotifArea is true', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: true}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'Auto'})).toBeVisibleViaBinding(); + }); + + it('hides auto visualization when exposeMotifArea is false', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: false}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'Auto'})).not.toBeVisibleViaBinding(); + }); + + it('shows manual visualization when exposeMotifArea is false', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: false}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'Manual'})).toBeVisibleViaBinding(); + }); + + it('hides manual visualization when exposeMotifArea is true', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: true}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'Manual'})).not.toBeVisibleViaBinding(); + }); + + it('shows sideBySide visualization when exposeMotifArea is true', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: true}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'SideBySide'})).toBeVisibleViaBinding(); + }); + + it('hides sideBySide visualization when exposeMotifArea is false', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: false}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'SideBySide'})).not.toBeVisibleViaBinding(); + }); + + it.each(['center', 'centerRagged']) + ('hides sideBySide visualization when layout is %s', (layout) => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: true, layout}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'SideBySide'})).not.toBeVisibleViaBinding(); + }); + + it.each(['center', 'centerRagged']) + ('hides portrait sideBySide visualization when layout is %s', async (layout) => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {portraitExposeMotifArea: true, layout, backdropImageMobile: 10}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole, getByText} = renderBackboneView(view); + + await user.click(getByText('Portrait')); + + expect(getByRole('img', {name: 'SideBySide'})).not.toBeVisibleViaBinding(); + }); + + it('shows motif area button when exposeMotifArea is true', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: true}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button', {name: 'Select motif area'})).toBeVisibleViaBinding(); + }); + + it('hides motif area button when exposeMotifArea is false', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: false}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button', {name: 'Select motif area'})).not.toBeVisibleViaBinding(); + }); + + it('opens dialog with backdropImage propertyName for image backdrop', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {exposeMotifArea: true, backdropType: 'image', backdropImage: 10}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('button', {name: 'Select motif area'})); + + expect(EditMotifAreaDialogView.show).toHaveBeenCalledWith( + expect.objectContaining({propertyName: 'backdropImage'}) + ); + }); + + it('opens dialog with backdropVideo propertyName for video backdrop', async () => { + const entry = createEntry({ + videoFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {exposeMotifArea: true, backdropType: 'video', backdropVideo: 10}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('button', {name: 'Select motif area'})); + + expect(EditMotifAreaDialogView.show).toHaveBeenCalledWith( + expect.objectContaining({propertyName: 'backdropVideo'}) + ); + }); + + it('opens dialog with backdropImageMobile propertyName on portrait tab', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {portraitExposeMotifArea: true, backdropType: 'image', backdropImageMobile: 10}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole, getByText} = renderBackboneView(view); + + await user.click(getByText('Portrait')); + await user.click(getByRole('button', {name: 'Select motif area'})); + + expect(EditMotifAreaDialogView.show).toHaveBeenCalledWith( + expect.objectContaining({propertyName: 'backdropImageMobile'}) + ); + }); + + it('does not disable edit motif area button on portrait tab when same portrait paddings is enabled', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: { + portraitExposeMotifArea: true, + backdropImageMobile: 10, + customPortraitPaddings: false + }}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole, getByText} = renderBackboneView(view); + + await user.click(getByText('Portrait')); + + expect(getByRole('button', {name: 'Select motif area'})).not.toBeDisabled(); + }); + + describe('with auto mode but no motif area defined', () => { + it('disables sideBySide visualization', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: true}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'SideBySide'}).closest('.input').classList).toContain('input-disabled'); + }); + + it('disables paddingTop slider', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: true}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + renderBackboneView(view); + + expect(view.el.querySelector('.slider_input').classList).toContain('disabled'); + }); + }); + + describe('with manual mode and no motif area defined', () => { + it('does not disable paddingTop slider', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {exposeMotifArea: false}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + renderBackboneView(view); + + expect(view.el.querySelector('.slider_input').classList).not.toContain('disabled'); + }); + }); + + describe('with auto mode and motif area defined', () => { + it('does not disable sideBySide visualization', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: { + exposeMotifArea: true, + backdropImageMotifArea: {left: 0, top: 0, width: 50, height: 50} + }}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'SideBySide'}).closest('.input').classList).not.toContain('input-disabled'); + }); + + it('does not disable paddingTop slider', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: { + exposeMotifArea: true, + backdropImageMotifArea: {left: 0, top: 0, width: 50, height: 50} + }}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + renderBackboneView(view); + + expect(view.el.querySelector('.slider_input').classList).not.toContain('disabled'); + }); + + it('does not disable paddingTop slider for video backdrop with motif area', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: { + exposeMotifArea: true, + backdropType: 'video', + backdropVideoMotifArea: {left: 0, top: 0, width: 50, height: 50} + }}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + renderBackboneView(view); + + expect(view.el.querySelector('.slider_input').classList).not.toContain('disabled'); + }); + }); + + describe('portrait tab with same paddings checkbox checked', () => { + it('shows non-portrait exposeMotifArea value in radio button', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: { + exposeMotifArea: true, + portraitExposeMotifArea: false, + customPortraitPaddings: false, + backdropImageMobile: 10 + }}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole, getByText} = renderBackboneView(view); + + await user.click(getByText('Portrait')); + + expect(getByRole('radio', {name: 'Auto mode'})).toBeChecked(); + }); + + it('disables portrait paddingTop slider', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: { + customPortraitPaddings: false, + backdropImageMobile: 10 + }}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByText} = renderBackboneView(view); + + await user.click(getByText('Portrait')); + + const sliders = view.el.querySelectorAll('.slider_input'); + expect(sliders[0].classList).toContain('disabled'); + }); + + it('switches to portrait auto mode and reveals elements when unchecking checkbox', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: { + exposeMotifArea: false, + portraitExposeMotifArea: true, + customPortraitPaddings: false, + backdropImageMobile: 10 + }}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole, getByText} = renderBackboneView(view); + + await user.click(getByText('Portrait')); + + // Initially shows manual mode (from non-portrait exposeMotifArea) + expect(getByRole('radio', {name: 'Manual mode'})).toBeChecked(); + expect(getByRole('img', {name: 'SideBySide'})).not.toBeVisibleViaBinding(); + expect(getByRole('button', {name: 'Select motif area'})).not.toBeVisibleViaBinding(); + + await user.click(getByRole('checkbox', {name: 'Same as landscape'})); + + // Now shows auto mode (from portrait exposeMotifArea) + expect(getByRole('radio', {name: 'Auto mode'})).toBeChecked(); + expect(getByRole('img', {name: 'SideBySide'})).toBeVisibleViaBinding(); + expect(getByRole('button', {name: 'Select motif area'})).toBeVisibleViaBinding(); + }); + }); + + describe('with backdrop type color', () => { + it('does not disable paddingTop slider even with exposeMotifArea true', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'color', exposeMotifArea: true}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + renderBackboneView(view); + + expect(view.el.querySelector('.slider_input').classList).not.toContain('disabled'); + }); + + it('hides intersecting auto visualization', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'color'}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'Auto'})).not.toBeVisibleViaBinding(); + }); + + it('hides intersecting manual visualization', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'color'}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'Manual'})).not.toBeVisibleViaBinding(); + }); + + it('shows topPadding visualization instead', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'color'}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('img', {name: 'TopPadding'})).toBeVisibleViaBinding(); + }); + + it('hides motif area button', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'color', exposeMotifArea: true}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button', {name: 'Select motif area'})).not.toBeVisibleViaBinding(); + }); + }); + + describe('portrait tab visibility', () => { + it('hides portrait tab when backdropType is color', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'color'}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {queryByRole} = renderBackboneView(view); + + expect(queryByRole('tab', {name: 'Portrait'})).not.toBeInTheDocument(); + }); + + it('hides portrait tab when no portrait image is present', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'image'}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {queryByRole} = renderBackboneView(view); + + expect(queryByRole('tab', {name: 'Portrait'})).not.toBeInTheDocument(); + }); + + it('hides portrait tab when no portrait video is present', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropType: 'video'}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {queryByRole} = renderBackboneView(view); + + expect(queryByRole('tab', {name: 'Portrait'})).not.toBeInTheDocument(); + }); + + it('shows portrait tab when portrait image is present', () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImageMobile: 10}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('tab', {name: 'Portrait'})).toBeInTheDocument(); + }); + + it('shows portrait tab when portrait video is present', () => { + const entry = createEntry({ + videoFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropType: 'video', backdropVideoMobile: 10}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('tab', {name: 'Portrait'})).toBeInTheDocument(); + }); + }); + + describe('emulation mode toggling', () => { + it('defaults to portrait tab when emulation_mode is phone', () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImageMobile: 10}}] + }); + entry.set('emulation_mode', 'phone'); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('tab', {name: 'Portrait'})).toHaveAttribute('aria-selected', 'true'); + }); + + it('sets emulation_mode to phone when switching to portrait tab', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImageMobile: 10}}] + }); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('tab', {name: 'Portrait'})); + + expect(entry.get('emulation_mode')).toEqual('phone'); + }); + + it('unsets emulation_mode when switching back to sectionPaddings tab', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImageMobile: 10}}] + }); + entry.set('emulation_mode', 'phone'); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('tab', {name: 'Portrait'})); + await user.click(getByRole('tab', {name: 'Landscape'})); + + expect(entry.has('emulation_mode')).toEqual(false); + }); + }); + + describe('scrollToSection events', () => { + it('triggers scrollToSection when starting to drag paddingTop slider', () => { + const entry = createEntry({ + sections: [{id: 1}] + }); + const listener = jest.fn(); + entry.on('scrollToSection', listener); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + renderBackboneView(view); + + view.$el.find('.slider_input').eq(0).trigger('slidestart'); + + expect(listener).toHaveBeenCalledWith(entry.sections.get(1), {ifNeeded: true}); + }); + + it('triggers scrollToSection to end when starting to drag paddingBottom slider', () => { + const entry = createEntry({ + sections: [{id: 1}] + }); + const listener = jest.fn(); + entry.on('scrollToSection', listener); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + renderBackboneView(view); + + view.$el.find('.slider_input').eq(1).trigger('slidestart'); + + expect(listener).toHaveBeenCalledWith(entry.sections.get(1), {align: 'nearEnd', ifNeeded: true}); + }); + + it('triggers scrollToSection when starting to drag portraitPaddingTop slider', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImageMobile: 10, customPortraitPaddings: true}}] + }); + const listener = jest.fn(); + entry.on('scrollToSection', listener); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('tab', {name: 'Portrait'})); + + view.$el.find('.slider_input').eq(0).trigger('slidestart'); + + expect(listener).toHaveBeenCalledWith(entry.sections.get(1), {ifNeeded: true}); + }); + + it('triggers scrollToSection to end when starting to drag portraitPaddingBottom slider', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImageMobile: 10, customPortraitPaddings: true}}] + }); + const listener = jest.fn(); + entry.on('scrollToSection', listener); + + const view = new EditSectionPaddingsView({ + model: entry.sections.get(1), + entry + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('tab', {name: 'Portrait'})); + + view.$el.find('.slider_input').eq(1).trigger('slidestart'); + + expect(listener).toHaveBeenCalledWith(entry.sections.get(1), {align: 'nearEnd', ifNeeded: true}); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/views/inputs/EditMotifAreaInputView-spec.js b/entry_types/scrolled/package/spec/editor/views/inputs/EditMotifAreaInputView-spec.js new file mode 100644 index 0000000000..f0a3dda67f --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/inputs/EditMotifAreaInputView-spec.js @@ -0,0 +1,217 @@ +import '@testing-library/jest-dom/extend-expect'; + +import {EditMotifAreaInputView} from 'editor/views/inputs/EditMotifAreaInputView'; +import {EditMotifAreaDialogView} from 'editor/views/EditMotifAreaDialogView'; + +import styles from 'editor/views/inputs/EditMotifAreaInputView.module.css'; + +import {useFakeTranslations, renderBackboneView} from 'pageflow/testHelpers'; +import {useEditorGlobals} from 'support'; +import userEvent from '@testing-library/user-event'; + +jest.mock('editor/views/EditMotifAreaDialogView'); + +describe('EditMotifAreaInputView', () => { + const {createEntry} = useEditorGlobals(); + + useFakeTranslations({ + 'pageflow_scrolled.editor.edit_motif_area_input.select': 'Select motif area', + 'pageflow_scrolled.editor.edit_motif_area_input.edit': 'Edit motif area' + }); + + it('renders select button when no motif area defined', () => { + const entry = createEntry({ + sections: [{id: 1}] + }); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button', {name: 'Select motif area'})).toBeInTheDocument(); + }); + + it('renders edit button when motif area is present', () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImage: 10, backdropImageMotifArea: {left: 0, top: 0, width: 50, height: 50}}}] + }); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button', {name: 'Edit motif area'})).toBeInTheDocument(); + }); + + it('renders select button when motif area is present but file is missing', () => { + const entry = createEntry({ + sections: [{id: 1, configuration: {backdropImageMotifArea: {left: 0, top: 0, width: 50, height: 50}}}] + }); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button', {name: 'Select motif area'})).toBeInTheDocument(); + }); + + it('opens dialog with file looked up via getReference', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImage: 10}}] + }); + const file = entry.getFileCollection('image_files').get(100); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('button')); + + expect(EditMotifAreaDialogView.show).toHaveBeenCalledWith( + expect.objectContaining({ + propertyName: 'backdropImage', + file + }) + ); + }); + + it('looks up video file when backdropType is video', async () => { + const entry = createEntry({ + videoFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropType: 'video', backdropVideo: 10}}] + }); + const file = entry.getFileCollection('video_files').get(100); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('button')); + + expect(EditMotifAreaDialogView.show).toHaveBeenCalledWith( + expect.objectContaining({ + propertyName: 'backdropVideo', + file + }) + ); + }); + + it('looks up mobile image file on portrait tab', async () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImageMobile: 10}}] + }); + const file = entry.getFileCollection('image_files').get(100); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration, + portrait: true + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('button')); + + expect(EditMotifAreaDialogView.show).toHaveBeenCalledWith( + expect.objectContaining({ + propertyName: 'backdropImageMobile', + file + }) + ); + }); + + it('updates button text when motif area property changes', () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImage: 10}}] + }); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button', {name: 'Select motif area'})).toBeInTheDocument(); + + entry.sections.get(1).configuration.set( + 'backdropImageMotifArea', + {left: 0, top: 0, width: 50, height: 50} + ); + + expect(getByRole('button', {name: 'Edit motif area'})).toBeInTheDocument(); + }); + + it('shows check icon when motif area is present', () => { + const entry = createEntry({ + imageFiles: [{id: 100, perma_id: 10}], + sections: [{id: 1, configuration: {backdropImage: 10, backdropImageMotifArea: {left: 0, top: 0, width: 50, height: 50}}}] + }); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + renderBackboneView(view); + + expect(view.el.querySelector(`.${styles.checkIcon}`)).toBeVisible(); + }); + + it('hides check icon when no motif area defined', () => { + const entry = createEntry({ + sections: [{id: 1}] + }); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + renderBackboneView(view); + + expect(view.el.querySelector(`.${styles.checkIcon}`)).not.toBeVisible(); + }); + + it('disables button when disabled option is true', () => { + const entry = createEntry({ + sections: [{id: 1}] + }); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration, + disabled: true + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button')).toBeDisabled(); + }); + + it('disables button when file is not present', () => { + const entry = createEntry({ + sections: [{id: 1}] + }); + + const view = new EditMotifAreaInputView({ + model: entry.sections.get(1).configuration + }); + + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button')).toBeDisabled(); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/views/inputs/SectionPaddingsInputView-spec.js b/entry_types/scrolled/package/spec/editor/views/inputs/SectionPaddingsInputView-spec.js new file mode 100644 index 0000000000..fd645587b6 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/inputs/SectionPaddingsInputView-spec.js @@ -0,0 +1,78 @@ +import '@testing-library/jest-dom/extend-expect'; + +import {SectionPaddingsInputView} from 'editor/views/inputs/SectionPaddingsInputView'; +import {editor} from 'pageflow/editor'; + +import {useFakeTranslations, renderBackboneView} from 'pageflow/testHelpers'; +import {useEditorGlobals} from 'support'; +import userEvent from '@testing-library/user-event'; + +describe('SectionPaddingsInputView', () => { + const {createEntry} = useEditorGlobals(); + + useFakeTranslations({ + 'pageflow_scrolled.editor.section_paddings_input.auto': 'Auto' + }); + + beforeEach(() => { + jest.spyOn(editor, 'navigate').mockImplementation(() => {}); + }); + + it('triggers scrollToSection when clicking button', async () => { + const entry = createEntry({ + sections: [{id: 1}] + }); + const listener = jest.fn(); + entry.on('scrollToSection', listener); + + const view = new SectionPaddingsInputView({ + model: entry.sections.get(1).configuration, + entry + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('button')); + + expect(listener).toHaveBeenCalledWith(entry.sections.get(1), {ifNeeded: true}); + }); + + it('navigates to paddings editor when clicking button', async () => { + const entry = createEntry({ + sections: [{id: 1}] + }); + + const view = new SectionPaddingsInputView({ + model: entry.sections.get(1).configuration, + entry + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('button')); + + expect(editor.navigate).toHaveBeenCalledWith('/scrolled/sections/1/paddings', {trigger: true}); + }); + + it('triggers selectSectionPaddings when clicking button', async () => { + const entry = createEntry({ + sections: [{id: 1}] + }); + const listener = jest.fn(); + entry.on('selectSectionPaddings', listener); + + const view = new SectionPaddingsInputView({ + model: entry.sections.get(1).configuration, + entry + }); + + const user = userEvent.setup(); + const {getByRole} = renderBackboneView(view); + + await user.click(getByRole('button')); + + expect(listener).toHaveBeenCalledWith(entry.sections.get(1)); + }); +}); 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 0c98f46789..032cc194c0 100644 --- a/entry_types/scrolled/package/spec/frontend/features/scrollToSectionMessage-spec.js +++ b/entry_types/scrolled/package/spec/frontend/features/scrollToSectionMessage-spec.js @@ -66,6 +66,207 @@ describe('SCROLL_TO_SECTION message', () => { }); }); + it('scrolls to bottom of section with 25% offset when align is nearEnd', async () => { + const {fakeSectionBoundingClientRectsByPermaId} = renderEntry({ + seed: { + sections: [ + {id: 100, permaId: 10}, + {id: 101, permaId: 11} + ] + } + }); + + window.scrollY = 1000; + window.innerHeight = 1000 + fakeSectionBoundingClientRectsByPermaId({ + 10: {top: -100, height: 800}, + 11: {top: 700} + }); + + await asyncHandlingOf(() => { + window.postMessage({type: 'SCROLL_TO_SECTION', payload: {id: 100, align: 'nearEnd'}}, '*'); + }); + + // Scroll so bottom of section is at 25% from viewport bottom (75% from top) + // offset = height - innerHeight * 0.75 = 800 - 750 = 50 + // top = -100 + 1000 + 50 = 950 + expect(window.scrollTo).toHaveBeenCalledWith({ + top: 950, + behavior: 'smooth' + }); + }); + + it('does not scroll when ifNeeded is true and section top is visible with default align', async () => { + const {fakeSectionBoundingClientRectsByPermaId} = renderEntry({ + seed: { + sections: [ + {id: 100, permaId: 10}, + {id: 101, permaId: 11} + ] + } + }); + + window.scrollY = 1000; + window.innerHeight = 1000 + fakeSectionBoundingClientRectsByPermaId({ + 10: {top: 100, height: 800}, + 11: {top: 900} + }); + + await asyncHandlingOf(() => { + window.postMessage({type: 'SCROLL_TO_SECTION', payload: {id: 100, ifNeeded: true}}, '*'); + }); + + expect(window.scrollTo).not.toHaveBeenCalled(); + }); + + it('scrolls when ifNeeded is true and section top is above viewport with default align', async () => { + const {fakeSectionBoundingClientRectsByPermaId} = renderEntry({ + seed: { + sections: [ + {id: 100, permaId: 10}, + {id: 101, permaId: 11} + ] + } + }); + + window.scrollY = 1000; + window.innerHeight = 1000 + fakeSectionBoundingClientRectsByPermaId({ + 10: {top: -100, height: 800}, + 11: {top: 700} + }); + + await asyncHandlingOf(() => { + window.postMessage({type: 'SCROLL_TO_SECTION', payload: {id: 100, ifNeeded: true}}, '*'); + }); + + expect(window.scrollTo).toHaveBeenCalled(); + }); + + it('does not scroll when ifNeeded is true and section top is visible', async () => { + const {fakeSectionBoundingClientRectsByPermaId} = renderEntry({ + seed: { + sections: [ + {id: 100, permaId: 10}, + {id: 101, permaId: 11} + ] + } + }); + + window.scrollY = 1000; + window.innerHeight = 1000 + fakeSectionBoundingClientRectsByPermaId({ + 10: {top: 100, height: 800}, + 11: {top: 900} + }); + + await asyncHandlingOf(() => { + window.postMessage({type: 'SCROLL_TO_SECTION', payload: {id: 100, align: 'start', ifNeeded: true}}, '*'); + }); + + expect(window.scrollTo).not.toHaveBeenCalled(); + }); + + it('scrolls when ifNeeded is true and section top is above viewport', async () => { + const {fakeSectionBoundingClientRectsByPermaId} = renderEntry({ + seed: { + sections: [ + {id: 100, permaId: 10}, + {id: 101, permaId: 11} + ] + } + }); + + window.scrollY = 1000; + window.innerHeight = 1000 + fakeSectionBoundingClientRectsByPermaId({ + 10: {top: -100, height: 800}, + 11: {top: 700} + }); + + await asyncHandlingOf(() => { + window.postMessage({type: 'SCROLL_TO_SECTION', payload: {id: 100, align: 'start', ifNeeded: true}}, '*'); + }); + + expect(window.scrollTo).toHaveBeenCalled(); + }); + + it('does not scroll when ifNeeded is true and section bottom is visible for nearEnd', async () => { + const {fakeSectionBoundingClientRectsByPermaId} = renderEntry({ + seed: { + sections: [ + {id: 100, permaId: 10}, + {id: 101, permaId: 11} + ] + } + }); + + window.scrollY = 1000; + window.innerHeight = 1000 + fakeSectionBoundingClientRectsByPermaId({ + 10: {top: 100, height: 800}, + 11: {top: 900} + }); + + await asyncHandlingOf(() => { + window.postMessage({type: 'SCROLL_TO_SECTION', payload: {id: 100, align: 'nearEnd', ifNeeded: true}}, '*'); + }); + + expect(window.scrollTo).not.toHaveBeenCalled(); + }); + + it('scrolls when ifNeeded is true and section bottom is below viewport for nearEnd', async () => { + const {fakeSectionBoundingClientRectsByPermaId} = renderEntry({ + seed: { + sections: [ + {id: 100, permaId: 10}, + {id: 101, permaId: 11} + ] + } + }); + + window.scrollY = 1000; + window.innerHeight = 1000 + fakeSectionBoundingClientRectsByPermaId({ + 10: {top: 300, height: 800}, + 11: {top: 1100} + }); + + await asyncHandlingOf(() => { + window.postMessage({type: 'SCROLL_TO_SECTION', payload: {id: 100, align: 'nearEnd', ifNeeded: true}}, '*'); + }); + + expect(window.scrollTo).toHaveBeenCalled(); + }); + + it('scrolls instantly when behavior is instant', 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, behavior: 'instant'}}, '*'); + }); + + expect(window.scrollTo).toHaveBeenCalledWith({ + top: -100 + 1000 - 250, + behavior: 'instant' + }); + }); + it('activates excursion of section', async () => { renderEntry({ seed: { diff --git a/entry_types/scrolled/package/spec/support/toBeVisibleViaBinding.js b/entry_types/scrolled/package/spec/support/toBeVisibleViaBinding.js new file mode 100644 index 0000000000..8faf7f736a --- /dev/null +++ b/entry_types/scrolled/package/spec/support/toBeVisibleViaBinding.js @@ -0,0 +1,12 @@ +expect.extend({ + toBeVisibleViaBinding(element) { + const pass = !element.closest('.hidden_via_binding'); + + return { + pass, + message: () => pass + ? `expected element to have 'hidden_via_binding' class on itself or ancestors` + : `expected element not to have 'hidden_via_binding' class on itself or ancestors` + }; + } +}); diff --git a/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js b/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js index 75ae443f87..e63388df16 100644 --- a/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js +++ b/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js @@ -80,6 +80,16 @@ export const PreviewMessageController = Object.extend({ }) ); + this.listenTo(this.entry, 'selectSectionPaddings', section => + postMessage({ + type: 'SELECT', + payload: { + id: section.id, + type: 'sectionPaddings' + } + }) + ); + this.listenTo(this.entry, 'selectContentElement', (contentElement, options) => { postMessage({ type: 'SELECT', @@ -124,7 +134,9 @@ export const PreviewMessageController = Object.extend({ }); } else if (message.data.type === 'SELECTED') { - const {type, id} = message.data.payload; + const {type, id, position} = message.data.payload; + + this.preservedScrollTarget = null; if (type === 'contentElement') { const contentElement = this.entry.contentElements.get(id); @@ -134,7 +146,12 @@ export const PreviewMessageController = Object.extend({ this.editor.navigate(`/scrolled/sections/${id}`, {trigger: true}) } else if (type === 'sectionPaddings') { - this.editor.navigate(`/scrolled/sections/${id}/paddings`, {trigger: true}) + this.preservedScrollTarget = { + sectionId: id, + align: position === 'bottom' ? 'nearEnd' : undefined + }; + const query = position ? `?position=${position}` : ''; + this.editor.navigate(`/scrolled/sections/${id}/paddings${query}`, {trigger: true}) } else if (type === 'sectionTransition') { this.editor.navigate(`/scrolled/sections/${id}/transition`, {trigger: true}) @@ -201,7 +218,17 @@ export const PreviewMessageController = Object.extend({ }, preserveScrollPoint(callback) { - this.currentScrollPointCallback = callback; - this.iframeWindow.postMessage({type: 'SAVE_SCROLL_POINT'}, window.location.origin); + if (this.preservedScrollTarget) { + const {sectionId, align} = this.preservedScrollTarget; + callback(); + this.iframeWindow.postMessage({ + type: 'SCROLL_TO_SECTION', + payload: {id: sectionId, align, behavior: 'instant'} + }, window.location.origin); + } + else { + this.currentScrollPointCallback = callback; + this.iframeWindow.postMessage({type: 'SAVE_SCROLL_POINT'}, window.location.origin); + } } }); diff --git a/entry_types/scrolled/package/src/editor/controllers/SideBarController.js b/entry_types/scrolled/package/src/editor/controllers/SideBarController.js index f54be189d9..acf491cce4 100644 --- a/entry_types/scrolled/package/src/editor/controllers/SideBarController.js +++ b/entry_types/scrolled/package/src/editor/controllers/SideBarController.js @@ -38,11 +38,12 @@ export const SideBarController = Marionette.Controller.extend({ })); }, - sectionPaddings: function(id, tab) { + sectionPaddings: function(id, position) { this.region.show(new EditSectionPaddingsView({ entry: this.entry, model: this.entry.sections.get(id), - editor + editor, + position })); }, diff --git a/entry_types/scrolled/package/src/editor/routers/SideBarRouter.js b/entry_types/scrolled/package/src/editor/routers/SideBarRouter.js index c07ed6fe70..d89565d313 100644 --- a/entry_types/scrolled/package/src/editor/routers/SideBarRouter.js +++ b/entry_types/scrolled/package/src/editor/routers/SideBarRouter.js @@ -4,6 +4,7 @@ export const SideBarRouter = Marionette.AppRouter.extend({ appRoutes: { 'scrolled/chapters/:id': 'chapter', 'scrolled/sections/:id/transition': 'sectionTransition', + 'scrolled/sections/:id/paddings?position=:position': 'sectionPaddings', 'scrolled/sections/:id/paddings': 'sectionPaddings', 'scrolled/sections/:id': 'section', 'scrolled/content_elements/:id': 'contentElement' diff --git a/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js b/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js index 062ae6b4f9..87373c6847 100644 --- a/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js +++ b/entry_types/scrolled/package/src/editor/views/EditSectionPaddingsView.js @@ -1,8 +1,16 @@ -import {EditConfigurationView} from 'pageflow/editor'; -import {SelectInputView} from 'pageflow/ui'; +import I18n from 'i18n-js'; +import {EditConfigurationView, SeparatorView} from 'pageflow/editor'; +import {SliderInputView, RadioButtonGroupInputView, CheckBoxInputView} from 'pageflow/ui'; + +import {SectionPaddingVisualizationView} from './inputs/SectionPaddingVisualizationView'; +import {EditMotifAreaInputView} from './inputs/EditMotifAreaInputView'; +import paddingTopIcon from './images/paddingTop.svg'; +import paddingBottomIcon from './images/paddingBottom.svg'; import styles from './EditSectionPaddingsView.module.css'; +const i18nPrefix = 'pageflow_scrolled.editor.edit_section_paddings'; + export const EditSectionPaddingsView = EditConfigurationView.extend({ translationKeyPrefix: 'pageflow_scrolled.editor.edit_section_paddings', hideDestroyButton: true, @@ -13,37 +21,200 @@ export const EditSectionPaddingsView = EditConfigurationView.extend({ return `/scrolled/sections/` + this.model.get('id') }, + defaultTab() { + if (this.options.entry.get('emulation_mode') === 'phone') { + return 'portrait'; + } + }, + configure: function(configurationEditor) { const entry = this.options.entry; + const section = this.model; + const configuration = section.configuration; const [paddingTopValues, paddingTopTexts] = entry.getScale('sectionPaddingTop'); const [paddingBottomValues, paddingBottomTexts] = entry.getScale('sectionPaddingBottom'); + const hasPortrait = hasPortraitBackdrop(configuration); + configurationEditor.tab('sectionPaddings', function() { - this.input('paddingTop', SelectInputView, { - includeBlank: true, - values: paddingTopValues, - texts: paddingTopTexts - }); + if (hasPortrait && entry.has('emulation_mode')) { + entry.unset('emulation_mode'); + } - this.input('paddingBottom', SelectInputView, { - includeBlank: true, - values: paddingBottomValues, - texts: paddingBottomTexts - }); + paddingInputs(this, {entry, section, paddingTopValues, paddingTopTexts, paddingBottomValues, paddingBottomTexts}); }); + + if (!hasPortrait) { + return; + } + configurationEditor.tab('portrait', function() { - this.input('portraitPaddingTop', SelectInputView, { - includeBlank: true, - values: paddingTopValues, - texts: paddingTopTexts + if (!entry.has('emulation_mode')) { + entry.set('emulation_mode', 'phone'); + } + + this.listenTo(this.model, 'change:customPortraitPaddings', () => { + configurationEditor.refresh(); + }); + + this.input('samePortraitPaddings', CheckBoxInputView, { + storeInverted: 'customPortraitPaddings' }); - this.input('portraitPaddingBottom', SelectInputView, { - includeBlank: true, - values: paddingBottomValues, - texts: paddingBottomTexts + const usePortraitProperties = this.model.get('customPortraitPaddings'); + + paddingInputs(this, { + entry, + section, + prefix: usePortraitProperties ? 'portrait' : '', + portrait: true, + paddingTopValues, paddingTopTexts, paddingBottomValues, paddingBottomTexts, + disabledOptions: usePortraitProperties ? {} : {disabled: true} }); }); } }); + +function paddingInputs(tab, options) { + const { + entry, + section, + prefix = '', + portrait = false, + paddingTopValues, paddingTopTexts, paddingBottomValues, paddingBottomTexts, + disabledOptions + } = options; + + const paddingTopProperty = prefix ? `${prefix}PaddingTop` : 'paddingTop'; + const paddingBottomProperty = prefix ? `${prefix}PaddingBottom` : 'paddingBottom'; + const exposeMotifArea = prefix ? `${prefix}ExposeMotifArea` : 'exposeMotifArea'; + + const scrollToSectionStart = () => { + entry.trigger('scrollToSection', section, {ifNeeded: true}); + }; + const scrollToSectionEnd = () => { + entry.trigger('scrollToSection', section, {align: 'nearEnd', ifNeeded: true}); + }; + + tab.input('topPaddingVisualization', SectionPaddingVisualizationView, { + variant: 'intersectingAuto', + portrait, + visibleBinding: [exposeMotifArea, ...motifAreaBinding], + visible: ([exposeMotifAreaValue, ...motifAreaValues]) => + exposeMotifAreaValue && !motifAreaUnavailable(motifAreaValues), + ...disabledOptions + }); + tab.input('topPaddingVisualization', SectionPaddingVisualizationView, { + variant: 'intersectingManual', + portrait, + visibleBinding: [exposeMotifArea, ...motifAreaBinding], + visible: ([exposeMotifAreaValue, ...motifAreaValues]) => + !exposeMotifAreaValue && !motifAreaUnavailable(motifAreaValues), + ...disabledOptions + }); + tab.input('topPaddingVisualization', SectionPaddingVisualizationView, { + variant: 'topPadding', + portrait, + visibleBinding: motifAreaBinding, + visible: motifAreaUnavailable, + ...disabledOptions + }); + tab.input(exposeMotifArea, RadioButtonGroupInputView, { + hideLabel: true, + values: [true, false], + texts: [ + I18n.t(`${i18nPrefix}.attributes.exposeMotifArea.values.true`), + I18n.t(`${i18nPrefix}.attributes.exposeMotifArea.values.false`) + ], + visibleBinding: motifAreaBinding, + visible: values => !motifAreaUnavailable(values), + ...disabledOptions + }); + tab.input('editMotifArea', EditMotifAreaInputView, { + hideLabel: true, + portrait, + visibleBinding: [exposeMotifArea, ...motifAreaBinding], + visible: ([exposeMotifAreaValue, ...motifAreaValues]) => + exposeMotifAreaValue && !motifAreaUnavailable(motifAreaValues) + }); + + const imageMotifAreaPropertyName = portrait ? 'backdropImageMobileMotifArea' : 'backdropImageMotifArea'; + const videoMotifAreaPropertyName = portrait ? 'backdropVideoMobileMotifArea' : 'backdropVideoMotifArea'; + + const motifAreaNotDefinedBinding = [ + exposeMotifArea, 'backdropType', imageMotifAreaPropertyName, videoMotifAreaPropertyName + ]; + + tab.input('sideBySideVisualization', SectionPaddingVisualizationView, { + hideLabel: true, + variant: 'sideBySide', + portrait, + infoText: I18n.t(`${i18nPrefix}.side_by_side_info`), + visibleBinding: [exposeMotifArea, 'layout', ...motifAreaBinding], + visible: ([exposeMotifAreaValue, layout, ...motifAreaValues]) => + exposeMotifAreaValue && + layout !== 'center' && + layout !== 'centerRagged' && + !motifAreaUnavailable(motifAreaValues), + disabledBinding: motifAreaNotDefinedBinding, + disabled: motifAreaNotDefined + }); + + tab.input(paddingTopProperty, SliderInputView, { + hideLabel: true, + icon: paddingTopIcon, + texts: paddingTopTexts, + values: paddingTopValues, + saveOnSlide: true, + onInteractionStart: scrollToSectionStart, + disabledBinding: motifAreaNotDefinedBinding, + disabled: motifAreaNotDefined, + ...disabledOptions + }); + + tab.view(SeparatorView); + + tab.input('bottomPaddingVisualization', SectionPaddingVisualizationView, { + variant: 'bottomPadding', + portrait, + ...disabledOptions + }); + tab.input(paddingBottomProperty, SliderInputView, { + hideLabel: true, + icon: paddingBottomIcon, + texts: paddingBottomTexts, + values: paddingBottomValues, + saveOnSlide: true, + onInteractionStart: scrollToSectionEnd, + ...disabledOptions + }); +} + +const motifAreaBinding = ['backdropType']; + +function motifAreaUnavailable([backdropType]) { + return backdropType === 'color'; +} + +function motifAreaNotDefined([exposeMotifAreaValue, backdropType, imageMotifArea, videoMotifArea]) { + if (backdropType === 'color') { + return false; + } + + const motifArea = backdropType === 'video' ? videoMotifArea : imageMotifArea; + return exposeMotifAreaValue && !motifArea; +} + +function hasPortraitBackdrop(configuration) { + const backdropType = configuration.get('backdropType'); + + if (backdropType === 'color') { + return false; + } + + const propertyName = backdropType === 'video' ? 'backdropVideoMobile' : 'backdropImageMobile'; + const collection = backdropType === 'video' ? 'video_files' : 'image_files'; + + return !!configuration.getReference(propertyName, collection); +} diff --git a/entry_types/scrolled/package/src/editor/views/images/paddingBottom.svg b/entry_types/scrolled/package/src/editor/views/images/paddingBottom.svg new file mode 100644 index 0000000000..8df4cf334f --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/images/paddingBottom.svg @@ -0,0 +1 @@ + diff --git a/entry_types/scrolled/package/src/editor/views/images/paddingTop.svg b/entry_types/scrolled/package/src/editor/views/images/paddingTop.svg new file mode 100644 index 0000000000..b62baa13b8 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/images/paddingTop.svg @@ -0,0 +1 @@ + diff --git a/entry_types/scrolled/package/src/editor/views/inputs/EditMotifAreaInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/EditMotifAreaInputView.js new file mode 100644 index 0000000000..5ce9d308de --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/EditMotifAreaInputView.js @@ -0,0 +1,80 @@ +import Marionette from 'backbone.marionette'; +import I18n from 'i18n-js'; + +import {cssModulesUtils, inputView} from 'pageflow/ui'; +import {buttonStyles} from 'pageflow-scrolled/editor'; + +import {EditMotifAreaDialogView} from '../EditMotifAreaDialogView'; + +import styles from './EditMotifAreaInputView.module.css'; + +export const EditMotifAreaInputView = Marionette.ItemView.extend({ + template: () => ` + + + `, + + mixins: [inputView], + + ui: cssModulesUtils.ui(styles, 'button', 'buttonText', 'checkIcon'), + + events: { + 'click button': function() { + EditMotifAreaDialogView.show({ + model: this.model, + propertyName: this.getPropertyName(), + file: this.getFile() + }); + } + }, + + onRender() { + this.update(); + this.listenTo( + this.model, + 'change:' + this.getPropertyName() + 'MotifArea', + this.update + ); + }, + + update() { + const file = this.getFile(); + const hasMotifArea = !!(file && this.model.get(this.getPropertyName() + 'MotifArea')); + const key = hasMotifArea ? 'edit' : 'select'; + + this.ui.buttonText.text(I18n.t(`pageflow_scrolled.editor.edit_motif_area_input.${key}`)); + this.ui.checkIcon.toggle(hasMotifArea); + }, + + updateDisabled(disabled) { + this.ui.button.prop('disabled', disabled || !this.getFile()); + }, + + getPropertyName() { + const backdropType = this.model.get('backdropType'); + + if (this.options.portrait) { + return backdropType === 'video' ? 'backdropVideoMobile' : 'backdropImageMobile'; + } + else { + return backdropType === 'video' ? 'backdropVideo' : 'backdropImage'; + } + }, + + getFile() { + const collection = this.model.get('backdropType') === 'video' ? 'video_files' : 'image_files'; + return this.model.getReference(this.getPropertyName(), collection); + } +}); diff --git a/entry_types/scrolled/package/src/editor/views/inputs/EditMotifAreaInputView.module.css b/entry_types/scrolled/package/src/editor/views/inputs/EditMotifAreaInputView.module.css new file mode 100644 index 0000000000..3cea99d900 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/EditMotifAreaInputView.module.css @@ -0,0 +1,24 @@ +.button { + width: 100%; + display: flex !important; + align-items: center; + gap: space(2); +} + +.icon { + width: 16px; + height: 16px; + stroke: currentColor; + stroke-width: 2; + fill: none; + flex-shrink: 0; +} + +.buttonText { +} + +.checkIcon { + stroke: currentColor; + stroke-width: 2; + fill: none; +} diff --git a/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingVisualizationView.js b/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingVisualizationView.js new file mode 100644 index 0000000000..5fe446db21 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingVisualizationView.js @@ -0,0 +1,810 @@ +import Marionette from 'backbone.marionette'; +import {inputView} from 'pageflow/ui'; +import I18n from 'i18n-js'; + +import styles from './SectionPaddingVisualizationView.module.css'; + +const prefix = 'pageflow_scrolled.editor.section_padding_visualization'; + +export const SectionPaddingVisualizationView = Marionette.ItemView.extend({ + mixins: [inputView], + + template: (data) => ` + + ${data.infoText ? `
${data.infoText}
` : ''} +
+ `, + + serializeData() { + return { + infoText: this.options.infoText + }; + }, + + ui: { + preview: `.${styles.preview}` + }, + + onRender() { + this.listenTo(this.model, 'change:layout', this.update); + this.update(); + }, + + update() { + const svg = this.getSvg(); + this.ui.preview.html(svg); + }, + + getSvg() { + const portrait = this.options.portrait; + const layout = this.model.get('layout') || 'left'; + + switch (this.options.variant) { + case 'intersectingAuto': return intersectingAutoSvg(portrait, layout); + case 'intersectingManual': return intersectingManualSvg(portrait, layout); + case 'sideBySide': return sideBySideSvg(portrait, layout); + case 'topPadding': return topPaddingSvg(portrait, layout); + case 'bottomPadding': return bottomPaddingSvg(portrait, layout); + default: return ''; + } + } +}); + +function intersectingAutoSvg(portrait, layout) { + if (portrait) { + return intersectingAutoSvgPortrait(layout); + } + + const right = layout === 'right'; + const center = layout === 'center' || layout === 'centerRagged'; + const centerRagged = layout === 'centerRagged'; + const arrowX = right ? '150;125;125;150' : '30;55;55;30'; + const textXY = right + ? '100 62;63 41;63 41;100 62' + : center + ? '55 62;55 41;55 41;55 62' + : '10 62;47 41;47 41;10 62'; + + return ` + + ${I18n.t(`${prefix}.intersecting_auto`)} + + + + + + + + +${diagonalStripesPattern('autoDiagonalStripes')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; +} + +function intersectingAutoSvgPortrait(layout) { + // Canvas: 142x142, animates viewport width from 80 (9:16) to 142 (square) + // Height stays fixed at 142. Only x and width animate. + // 9:16: x=31, width=80 | Square: x=0, width=142 + // Scale factor: 80/142 ≈ 0.563 (motif scales with viewport width) + const right = layout === 'right'; + const center = layout === 'center' || layout === 'centerRagged'; + const centerRagged = layout === 'centerRagged'; + const arrowX = right ? '104;129;129;104' : '38;13;13;38'; + const textX = right + ? '48 62;79 108;79 108;48 62' + : center + ? '44 62;44 108;44 108;44 62' + : '39 62;8 108;8 108;39 62'; + + return ` + + ${I18n.t(`${prefix}.intersecting_auto`)} + + + + + + + + +${diagonalStripesPattern('autoDiagonalStripesPortrait')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; +} + +function intersectingManualSvg(portrait, layout) { + if (portrait) { + return intersectingManualSvgPortrait(layout); + } + + const right = layout === 'right'; + const center = layout === 'center' || layout === 'centerRagged'; + const centerRagged = layout === 'centerRagged'; + const arrowX = right ? '125;150;150;125' : '55;30;30;55'; + const textX = right + ? '63;100;100;63' + : center + ? '55;55;55;55' + : '47;10;10;47'; + + return ` + + ${I18n.t(`${prefix}.intersecting_manual`)} + + + + + + + + +${diagonalStripesPattern('manualDiagonalStripes')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; +} + +function intersectingManualSvgPortrait(layout) { + // Canvas: 142x142, animates viewport width from 142 (square) to 80 (9:16) + // Height stays fixed at 142. Only x and width animate. + // Square: x=0, width=142 | 9:16: x=31, width=80 + // Scale factor: 142/80 ≈ 1.775 → 0.563 (motif scales with viewport width) + const right = layout === 'right'; + const center = layout === 'center' || layout === 'centerRagged'; + const centerRagged = layout === 'centerRagged'; + const arrowX = right ? '129;104;104;129' : '13;38;38;13'; + const textX = right + ? '79 50;48 50;48 50;79 50' + : center + ? '44 50;44 50;44 50;44 50' + : '8 50;39 50;39 50;8 50'; + + return ` + + ${I18n.t(`${prefix}.intersecting_manual`)} + + + + + + + + +${diagonalStripesPattern('manualDiagonalStripesPortrait')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; +} + +function sideBySideSvg(portrait, layout) { + // Center layout not supported for side-by-side, fall back to left + const right = layout === 'right'; + + if (portrait) { + const motifX = right ? 4 : 58; + const textX = right ? 47 : 8; + const arrowX = right ? 75 : 25; + + return ` + + ${I18n.t(`${prefix}.side_by_side`)} + + ${diagonalStripesPattern('diagonalStripesPortrait')} + + + + + + + + + ${staticArrow(arrowX, 5)} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + } + + const motifX = right ? 10 : 115; + const textX = right ? 90 : 10; + const arrowX = right ? 135 : 45; + + return ` + + ${I18n.t(`${prefix}.side_by_side`)} + + ${diagonalStripesPattern('diagonalStripes')} + + + + + + + + + ${staticArrow(arrowX, 5)} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; +} + +function topPaddingSvg(portrait, layout) { + const right = layout === 'right'; + const center = layout === 'center' || layout === 'centerRagged'; + const centerRagged = layout === 'centerRagged'; + + if (portrait) { + const textX = center ? 10 : right ? 10 : 10; + const arrowX = center ? 50 : 50; + + return ` + + ${I18n.t(`${prefix}.top_padding`)} + + ${diagonalStripesPattern('topPaddingStripesPortrait')} + + + + + + + + + ${staticArrow(arrowX, 5)} + + + + + + + + + + + + + + + + + + + `; + } + + const textX = right ? 90 : center ? 50 : 10; + const arrowX = right ? 135 : center ? 90 : 45; + + return ` + + ${I18n.t(`${prefix}.top_padding`)} + + ${diagonalStripesPattern('topPaddingStripes')} + + + + + + + + + ${staticArrow(arrowX, 5)} + + + + + + + + + + + + + + + `; +} + +function bottomPaddingSvg(portrait, layout) { + const right = layout === 'right'; + const center = layout === 'center' || layout === 'centerRagged'; + const centerRagged = layout === 'centerRagged'; + + if (portrait) { + const textX = center ? 10 : right ? 10 : 10; + const arrowX = center ? 50 : 50; + + return ` + + ${I18n.t(`${prefix}.bottom_padding`)} + + ${diagonalStripesPattern('bottomPaddingStripesPortrait')} + + + + + + + + + + + + + + + + + + + + + + + + + + ${staticArrow(arrowX, 85)} + + `; + } + + const textX = right ? 90 : center ? 50 : 10; + const arrowX = right ? 135 : center ? 90 : 45; + + return ` + + ${I18n.t(`${prefix}.bottom_padding`)} + + ${diagonalStripesPattern('bottomPaddingStripes')} + + + + + + + + + + + + + + + + + + + + + + ${staticArrow(arrowX, 55)} + + `; +} + +const easing = '0.4 0 0.2 1;0 0 1 1;0.4 0 0.2 1'; + +function diagonalStripesPattern(id) { + return ` + + + + `; +} + +function staticArrow(x, y) { + return ` + + + + + + `; +} diff --git a/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingVisualizationView.module.css b/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingVisualizationView.module.css new file mode 100644 index 0000000000..4583d77738 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingVisualizationView.module.css @@ -0,0 +1,53 @@ +.infoText { + margin: space(4) 0 space(2); +} + +.preview { + border: solid 1px var(--ui-on-surface-color-lightest); + border-radius: rounded(sm); + margin-bottom: space(1); + background-color: var(--ui-primary-color-lighter); + color: var(--ui-on-primary-color); + box-sizing: border-box; + overflow: hidden; +} + +:global(.input-disabled) .preview, +:global(.input-disabled) .infoText { + opacity: 0.5; +} + +.svg { + display: block; + aspect-ratio: 9 / 4; + max-height: 150px; + margin-left: auto; + margin-right: auto; +} + +.silhouette { + fill: currentColor; + opacity: 0.5; +} + +.cornerMarker { + stroke: currentColor; + stroke-width: 1.5; + fill: none; + opacity: 0.7; +} + +.spacingZone { + fill: url(#diagonalStripes); +} + +.arrow { + stroke: var(--ui-selection-color); + stroke-width: 1.5; + fill: none; +} + +.textBlock rect { + fill: currentColor; + opacity: 0.6; +} diff --git a/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingsInputView.js b/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingsInputView.js index 47fccd22f7..54b638b512 100644 --- a/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingsInputView.js +++ b/entry_types/scrolled/package/src/editor/views/inputs/SectionPaddingsInputView.js @@ -5,6 +5,8 @@ import {editor} from 'pageflow/editor'; import {buttonStyles} from 'pageflow-scrolled/editor'; import {cssModulesUtils, inputView} from 'pageflow/ui'; +import paddingTopIcon from '../images/paddingTop.svg'; +import paddingBottomIcon from '../images/paddingBottom.svg'; import styles from './SectionPaddingsInputView.module.css'; export const SectionPaddingsInputView = Marionette.Layout.extend({ @@ -17,12 +19,10 @@ export const SectionPaddingsInputView = Marionette.Layout.extend({