diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 580a53480..49e6c63b0 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1161,6 +1161,9 @@ de: duplicate_content_element_menu_item: label: Element duplizieren selection_label: Auswahl duplizieren + content_element_menu_items: + move: Element verschieben... + move_selection: Auswahl verschieben... destroy_section_menu_item: confirm_destroy: Abschnitt inklusive aller Elemente wirklich löschen? destroy: Abschnitt löschen @@ -1298,6 +1301,7 @@ de: hide: Außerhalb des Editors ausblenden insert_section_above: Abschnitt oberhalb einfügen insert_section_below: Abschnitt unterhalb einfügen + move: Abschnitt verschieben... reset_cutoff: Paywall Grenze entfernen set_cutoff: Paywall Grenze oberhalb setzen show: Außerhalb des Editors einblenden @@ -1320,10 +1324,22 @@ de: select_file: Biete eine Datei zum Download an select_file_description: Lasse Leser eine Datei herunterladen, wenn sie auf den Link klicken. select_in_sidebar: Datei auswählen + select_move_destination: + cancel: Abbrechen + header_insertPosition: Abschnitt verschieben + header_sectionPart: Element verschieben + hint: Wähle die Position aus, an die verschoben werden soll. + selectable_storyline_item: + blank_slate: Keine Kapitel + blank_slate_excursions: Keine Exkurse selectable_chapter_item: title: Kapitel auswählen + insert_here: Hierhin verschieben selectable_section_item: title: Abschnitt auswählen + insert_here: Hierhin verschieben + insert_at_beginning: An den Anfang + insert_at_end: Ans Ende edit_motif_area_menu_item: Motivbereich markieren... edit_motif_area_input: select: Motivbereich auswählen diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index dc5dba958..e7bc604fe 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1145,6 +1145,9 @@ en: duplicate_content_element_menu_item: label: Duplicate element selection_label: Duplicate selection + content_element_menu_items: + move: Move element... + move_selection: Move selection... destroy_section_menu_item: confirm_destroy: Really delete this section including all its elements? destroy: Delete section @@ -1282,6 +1285,7 @@ en: hide: Hide outside of the editor insert_section_above: Insert section above insert_section_below: Insert section below + move: Move section... reset_cutoff: Remove paywall cutoff set_cutoff: Set paywall cutoff above show: Show outside of the editor @@ -1304,10 +1308,22 @@ en: select_file: Provide file download select_file_description: Let readers download a file when they click the link. select_in_sidebar: Select file + select_move_destination: + cancel: Cancel + header_insertPosition: Move section + header_sectionPart: Move element + hint: Select the position to move to. + selectable_storyline_item: + blank_slate: No chapters + blank_slate_excursions: No excursions selectable_chapter_item: title: Select chapter + insert_here: Move here selectable_section_item: title: Select section + insert_here: Move here + insert_at_beginning: Move to beginning + insert_at_end: Move to end edit_motif_area_menu_item: Select motif area... edit_motif_area_input: select: Select motif area diff --git a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js index 332170cc4..fa24dad7b 100644 --- a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js @@ -264,6 +264,202 @@ describe('Chapter', () => { }); }); + describe('#moveSection', () => { + describe('within same chapter', () => { + beforeEach(() => { + testContext.entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + chapters: [{id: 10}], + sections: [ + {id: 100, chapterId: 10, position: 0}, + {id: 101, chapterId: 10, position: 1}, + {id: 102, chapterId: 10, position: 2} + ] + }) + }); + }); + + setupGlobals({ + entry: () => testContext.entry + }); + + useFakeXhr(() => testContext); + + it('re-indexes sections when moving after another section', () => { + const {entry} = testContext; + const chapter = entry.chapters.first(); + const sectionToMove = chapter.sections.get(100); + const targetSection = chapter.sections.get(102); + + chapter.moveSection(sectionToMove, {after: targetSection}); + + expect(chapter.sections.pluck('position')).toEqual([0, 1, 2]); + expect(chapter.sections.pluck('id')).toEqual([101, 102, 100]); + }); + + it('triggers selectSection and scrollToSection events', () => { + const {entry} = testContext; + const chapter = entry.chapters.first(); + const sectionToMove = chapter.sections.get(100); + const targetSection = chapter.sections.get(102); + const selectListener = jest.fn(); + const scrollListener = jest.fn(); + + entry.on('selectSection', selectListener); + entry.on('scrollToSection', scrollListener); + + chapter.moveSection(sectionToMove, {after: targetSection}); + + expect(selectListener).toHaveBeenCalledWith(sectionToMove); + expect(scrollListener).toHaveBeenCalledWith(sectionToMove); + }); + + it('calls saveOrder', () => { + const {entry, requests} = testContext; + const chapter = entry.chapters.first(); + const sectionToMove = chapter.sections.get(100); + const targetSection = chapter.sections.get(102); + + chapter.moveSection(sectionToMove, {after: targetSection}); + + expect(requests.length).toBe(1); + expect(requests[0].url).toContain('/order'); + }); + + it('re-indexes sections when moving before another section', () => { + const {entry} = testContext; + const chapter = entry.chapters.first(); + const sectionToMove = chapter.sections.get(102); + const targetSection = chapter.sections.get(100); + + chapter.moveSection(sectionToMove, {before: targetSection}); + + expect(chapter.sections.pluck('position')).toEqual([0, 1, 2]); + expect(chapter.sections.pluck('id')).toEqual([102, 100, 101]); + }); + }); + + describe('between chapters', () => { + beforeEach(() => { + testContext.entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + chapters: [{id: 10}, {id: 20}], + sections: [ + {id: 100, chapterId: 10, position: 0}, + {id: 101, chapterId: 10, position: 1}, + {id: 200, chapterId: 20, position: 0}, + {id: 201, chapterId: 20, position: 1} + ] + }) + }); + }); + + setupGlobals({ + entry: () => testContext.entry + }); + + useFakeXhr(() => testContext); + + it('moves section to different chapter', () => { + const {entry} = testContext; + const sourceChapter = entry.chapters.get(10); + const targetChapter = entry.chapters.get(20); + const sectionToMove = sourceChapter.sections.get(100); + const targetSection = targetChapter.sections.get(200); + + targetChapter.moveSection(sectionToMove, {after: targetSection}); + + expect(sectionToMove.get('chapterId')).toBe(20); + expect(sourceChapter.sections.pluck('id')).toEqual([101]); + expect(targetChapter.sections.pluck('id')).toEqual([200, 100, 201]); + }); + + it('updates positions in target chapter', () => { + const {entry} = testContext; + const sourceChapter = entry.chapters.get(10); + const targetChapter = entry.chapters.get(20); + const sectionToMove = sourceChapter.sections.get(100); + const targetSection = targetChapter.sections.get(200); + + targetChapter.moveSection(sectionToMove, {after: targetSection}); + + expect(sourceChapter.sections.pluck('position')).toEqual([1]); + expect(targetChapter.sections.pluck('position')).toEqual([0, 1, 2]); + }); + + it('calls saveOrder on target chapter', () => { + const {entry, requests} = testContext; + const sourceChapter = entry.chapters.get(10); + const targetChapter = entry.chapters.get(20); + const sectionToMove = sourceChapter.sections.get(100); + const targetSection = targetChapter.sections.get(200); + + targetChapter.moveSection(sectionToMove, {after: targetSection}); + + expect(requests.length).toBe(1); + expect(requests[0].url).toContain('/chapters/20/sections/order'); + }); + + it('moves section before target section in different chapter', () => { + const {entry} = testContext; + const sourceChapter = entry.chapters.get(10); + const targetChapter = entry.chapters.get(20); + const sectionToMove = sourceChapter.sections.get(100); + const targetSection = targetChapter.sections.get(201); + + targetChapter.moveSection(sectionToMove, {before: targetSection}); + + expect(sectionToMove.get('chapterId')).toBe(20); + expect(sourceChapter.sections.pluck('id')).toEqual([101]); + expect(targetChapter.sections.pluck('id')).toEqual([200, 100, 201]); + }); + }); + + describe('into empty chapter', () => { + beforeEach(() => { + testContext.entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + chapters: [{id: 10}, {id: 20}], + sections: [ + {id: 100, chapterId: 10, position: 0}, + {id: 101, chapterId: 10, position: 1} + ] + }) + }); + }); + + setupGlobals({ + entry: () => testContext.entry + }); + + useFakeXhr(() => testContext); + + it('moves section into empty chapter', () => { + const {entry} = testContext; + const sourceChapter = entry.chapters.get(10); + const targetChapter = entry.chapters.get(20); + const sectionToMove = sourceChapter.sections.get(100); + + targetChapter.moveSection(sectionToMove); + + expect(sectionToMove.get('chapterId')).toBe(20); + expect(sourceChapter.sections.pluck('id')).toEqual([101]); + expect(targetChapter.sections.pluck('id')).toEqual([100]); + }); + + it('sets position to 0 when moving into empty chapter', () => { + const {entry} = testContext; + const sourceChapter = entry.chapters.get(10); + const targetChapter = entry.chapters.get(20); + const sectionToMove = sourceChapter.sections.get(100); + + targetChapter.moveSection(sectionToMove); + + expect(targetChapter.sections.pluck('position')).toEqual([0]); + }); + }); + }); + describe('#isExcursion', () => { it('returns false for chapters in main storyline', () => { const entry = factories.entry(ScrolledEntry, {}, { diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/moveContentElement-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/moveContentElement-spec.js index cc051e125..8344ff7a1 100644 --- a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/moveContentElement-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/moveContentElement-spec.js @@ -62,6 +62,24 @@ describe('ScrolledEntry', () => { expect(entry.sections.first().contentElements.pluck('position')).toEqual([0, 1, 2]); }); + it('calls success callback after save', () => { + const {entry, server} = testContext; + const success = jest.fn(); + + entry.moveContentElement({id: 7}, {at: 'before', id: 5}, {success}); + + expect(success).not.toHaveBeenCalled(); + + 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(success).toHaveBeenCalled(); + }); + it('supports moving after other content element', () => { const {entry, requests} = testContext; diff --git a/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js index 098a95b17..d014a6dea 100644 --- a/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js @@ -1,9 +1,20 @@ -import {DestroyContentElementMenuItem, DuplicateContentElementMenuItem} from 'editor/models/contentElementMenuItems'; +import { + DestroyContentElementMenuItem, + DuplicateContentElementMenuItem, + MoveContentElementMenuItem +} from 'editor/models/contentElementMenuItems'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; +import {SelectMoveDestinationDialogView} from 'editor/views/SelectMoveDestinationDialogView'; import {useFakeTranslations} from 'pageflow/testHelpers'; import {factories, normalizeSeed} from 'support'; +jest.mock('editor/views/SelectMoveDestinationDialogView', () => ({ + SelectMoveDestinationDialogView: { + show: jest.fn() + } +})); + describe('ContentElementMenuItems', () => { describe('DuplicateContentElementMenuItem', () => { useFakeTranslations({ @@ -240,4 +251,195 @@ describe('ContentElementMenuItems', () => { expect(entry.deleteContentElement).not.toHaveBeenCalled(); }); }); + + describe('MoveContentElementMenuItem', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.content_element_menu_items.move': 'Move...', + 'pageflow_scrolled.editor.content_element_menu_items.move_selection': 'Move selection...' + }); + + beforeEach(() => { + SelectMoveDestinationDialogView.show.mockClear(); + }); + + it('has Move label', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1, typeName: 'textBlock'}] + }) + }); + const contentElement = entry.contentElements.get(1); + editor.contentElementTypes.register('textBlock', {}); + + const menuItem = new MoveContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + + expect(menuItem.get('label')).toBe('Move...'); + }); + + it('has Move selection label when handleMove is defined', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1, typeName: 'textBlock'}] + }) + }); + const contentElement = entry.contentElements.get(1); + editor.contentElementTypes.register('textBlock', {handleMove() {}}); + + const menuItem = new MoveContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + + expect(menuItem.get('label')).toBe('Move selection...'); + }); + + it('shows dialog when selected', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1, typeName: 'textBlock'}] + }) + }); + const contentElement = entry.contentElements.get(1); + editor.contentElementTypes.register('textBlock', {}); + + const menuItem = new MoveContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + + menuItem.selected(); + + expect(SelectMoveDestinationDialogView.show).toHaveBeenCalledWith({ + entry, + mode: 'sectionPart', + onSelect: expect.any(Function) + }); + }); + + it('moves content element to beginning and scrolls on success', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 10}, {id: 20}], + contentElements: [ + {id: 1, sectionId: 10, typeName: 'textBlock'}, + {id: 2, sectionId: 20, typeName: 'textBlock'}, + {id: 3, sectionId: 20, typeName: 'textBlock'} + ] + }) + }); + const contentElement = entry.contentElements.get(1); + const targetSection = entry.sections.get(20); + editor.contentElementTypes.register('textBlock', {}); + entry.moveContentElement = jest.fn(); + const scrollHandler = jest.fn(); + entry.on('scrollToSection', scrollHandler); + + const menuItem = new MoveContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + + menuItem.selected(); + + const onSelect = SelectMoveDestinationDialogView.show.mock.calls[0][0].onSelect; + onSelect({section: targetSection, part: 'beginning'}); + + expect(entry.moveContentElement).toHaveBeenCalledWith( + {id: 1}, + {at: 'before', id: 2}, + {success: expect.any(Function)} + ); + + entry.moveContentElement.mock.calls[0][2].success(); + + expect(scrollHandler).toHaveBeenCalledWith(targetSection, {align: 'nearStart'}); + }); + + it('moves content element to end and scrolls on success', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 10}, {id: 20}], + contentElements: [ + {id: 1, sectionId: 10, typeName: 'textBlock'}, + {id: 2, sectionId: 20, typeName: 'textBlock'}, + {id: 3, sectionId: 20, typeName: 'textBlock'} + ] + }) + }); + const contentElement = entry.contentElements.get(1); + const targetSection = entry.sections.get(20); + editor.contentElementTypes.register('textBlock', {}); + entry.moveContentElement = jest.fn(); + const scrollHandler = jest.fn(); + entry.on('scrollToSection', scrollHandler); + + const menuItem = new MoveContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + + menuItem.selected(); + + const onSelect = SelectMoveDestinationDialogView.show.mock.calls[0][0].onSelect; + onSelect({section: targetSection, part: 'end'}); + + expect(entry.moveContentElement).toHaveBeenCalledWith( + {id: 1}, + {at: 'after', id: 3}, + {success: expect.any(Function)} + ); + + entry.moveContentElement.mock.calls[0][2].success(); + + expect(scrollHandler).toHaveBeenCalledWith(targetSection, {align: 'nearEnd'}); + }); + + it('calls handleMove instead of moveContentElement if defined', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 10}, {id: 20}], + contentElements: [ + {id: 1, sectionId: 10, typeName: 'textBlock'}, + {id: 2, sectionId: 20, typeName: 'textBlock'} + ] + }) + }); + const contentElement = entry.contentElements.get(1); + const targetSection = entry.sections.get(20); + const handleMove = jest.fn(); + editor.contentElementTypes.register('textBlock', {handleMove}); + entry.moveContentElement = jest.fn(); + + const menuItem = new MoveContentElementMenuItem({}, { + contentElement, + entry, + editor + }); + + menuItem.selected(); + + const onSelect = SelectMoveDestinationDialogView.show.mock.calls[0][0].onSelect; + onSelect({section: targetSection, part: 'beginning'}); + + expect(handleMove).toHaveBeenCalledWith(contentElement, { + at: 'before', + id: 2 + }); + expect(entry.moveContentElement).not.toHaveBeenCalled(); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js index b6cc1285b..e46b8861f 100644 --- a/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js @@ -5,12 +5,20 @@ import { InsertSectionBelowMenuItem, CutoffSectionMenuItem, CopyPermalinkMenuItem, - DestroySectionMenuItem + DestroySectionMenuItem, + MoveSectionMenuItem } from 'editor/models/sectionMenuItems'; +import {SelectMoveDestinationDialogView} from 'editor/views/SelectMoveDestinationDialogView'; import {useFakeTranslations} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; +jest.mock('editor/views/SelectMoveDestinationDialogView', () => ({ + SelectMoveDestinationDialogView: { + show: jest.fn() + } +})); + describe('SectionMenuItems', () => { useFakeTranslations({ 'pageflow_scrolled.editor.section_menu_items.hide': 'Hide', @@ -21,6 +29,7 @@ describe('SectionMenuItems', () => { 'pageflow_scrolled.editor.section_menu_items.set_cutoff': 'Set cutoff', 'pageflow_scrolled.editor.section_menu_items.reset_cutoff': 'Reset cutoff', 'pageflow_scrolled.editor.section_menu_items.copy_permalink': 'Copy permalink', + 'pageflow_scrolled.editor.section_menu_items.move': 'Move...', 'pageflow_scrolled.editor.destroy_section_menu_item.destroy': 'Delete section', 'pageflow_scrolled.editor.destroy_section_menu_item.confirm_destroy': 'Really delete this section?' }); @@ -259,4 +268,94 @@ describe('SectionMenuItems', () => { expect(section.destroyWithDelay).toHaveBeenCalled(); }); }); + + describe('MoveSectionMenuItem', () => { + beforeEach(() => { + SelectMoveDestinationDialogView.show.mockClear(); + }); + + it('has Move label', () => { + const entry = createEntry({sections: [{id: 1}]}); + const section = entry.sections.get(1); + const menuItem = new MoveSectionMenuItem({}, {entry, section}); + + expect(menuItem.get('label')).toBe('Move...'); + }); + + it('shows dialog when selected', () => { + const entry = createEntry({sections: [{id: 1}]}); + const section = entry.sections.get(1); + const menuItem = new MoveSectionMenuItem({}, {entry, section}); + + menuItem.selected(); + + expect(SelectMoveDestinationDialogView.show).toHaveBeenCalledWith({ + entry, + mode: 'insertPosition', + onSelect: expect.any(Function) + }); + }); + + it('moves section after target when position is after', () => { + const entry = createEntry({ + chapters: [{id: 10}, {id: 20}], + sections: [ + {id: 1, chapterId: 10}, + {id: 2, chapterId: 20} + ] + }); + const section = entry.sections.get(1); + const targetSection = entry.sections.get(2); + const menuItem = new MoveSectionMenuItem({}, {entry, section}); + + menuItem.selected(); + + const onSelect = SelectMoveDestinationDialogView.show.mock.calls[0][0].onSelect; + targetSection.chapter.moveSection = jest.fn(); + onSelect({section: targetSection, position: 'after'}); + + expect(targetSection.chapter.moveSection).toHaveBeenCalledWith(section, {after: targetSection}); + }); + + it('moves section before target when position is before', () => { + const entry = createEntry({ + chapters: [{id: 10}, {id: 20}], + sections: [ + {id: 1, chapterId: 10}, + {id: 2, chapterId: 20} + ] + }); + const section = entry.sections.get(1); + const targetSection = entry.sections.get(2); + const menuItem = new MoveSectionMenuItem({}, {entry, section}); + + menuItem.selected(); + + const onSelect = SelectMoveDestinationDialogView.show.mock.calls[0][0].onSelect; + targetSection.chapter.moveSection = jest.fn(); + onSelect({section: targetSection, position: 'before'}); + + expect(targetSection.chapter.moveSection).toHaveBeenCalledWith(section, {before: targetSection}); + }); + + it('moves section into empty chapter when position is into', () => { + const entry = createEntry({ + chapters: [{id: 10}, {id: 20}], + sections: [ + {id: 1, chapterId: 10} + ] + }); + const section = entry.sections.get(1); + const targetChapter = entry.chapters.get(20); + const menuItem = new MoveSectionMenuItem({}, {entry, section}); + + menuItem.selected(); + + const onSelect = SelectMoveDestinationDialogView.show.mock.calls[0][0].onSelect; + targetChapter.moveSection = jest.fn(); + onSelect({chapter: targetChapter, position: 'into'}); + + expect(targetChapter.moveSection).toHaveBeenCalledWith(section); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/editor/views/SelectMoveDestinationDialogView-spec.js b/entry_types/scrolled/package/spec/editor/views/SelectMoveDestinationDialogView-spec.js new file mode 100644 index 000000000..35495c9fa --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/SelectMoveDestinationDialogView-spec.js @@ -0,0 +1,146 @@ +import {SelectMoveDestinationDialogView} from 'editor/views/SelectMoveDestinationDialogView'; +import {ScrolledEntry} from 'editor/models/ScrolledEntry'; + +import {factories, normalizeSeed} from 'support'; +import {useFakeTranslations, renderBackboneView as render} from 'pageflow/testHelpers'; + +import userEvent from '@testing-library/user-event'; + +describe('SelectMoveDestinationDialogView', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.select_move_destination.header_insertPosition': 'Move section', + 'pageflow_scrolled.editor.select_move_destination.header_sectionPart': 'Move element', + 'pageflow_scrolled.editor.select_move_destination.hint': 'Select the position to move to.', + 'pageflow_scrolled.editor.select_move_destination.cancel': 'Cancel', + 'pageflow_scrolled.editor.selectable_section_item.title': 'Select section', + 'pageflow_scrolled.editor.selectable_section_item.insert_here': 'Move here', + 'pageflow_scrolled.editor.selectable_section_item.insert_at_beginning': 'Move to beginning', + 'pageflow_scrolled.editor.selectable_section_item.insert_at_end': 'Move to end' + }); + + it('renders title for insertPosition mode', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 1, permaId: 10}] + }) + }); + const view = new SelectMoveDestinationDialogView({ + entry, + mode: 'insertPosition', + onSelect: jest.fn() + }); + + const {getByRole} = render(view); + + expect(getByRole('heading', {name: 'Move section'})).toBeTruthy(); + }); + + it('renders title for sectionPart mode', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 1, permaId: 10}] + }) + }); + const view = new SelectMoveDestinationDialogView({ + entry, + mode: 'sectionPart', + onSelect: jest.fn() + }); + + const {getByRole} = render(view); + + expect(getByRole('heading', {name: 'Move element'})).toBeTruthy(); + }); + + it('allows selecting section in insertPosition mode', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [ + {id: 1, permaId: 10} + ] + }) + }); + const listener = jest.fn(); + const view = new SelectMoveDestinationDialogView({ + entry, + mode: 'insertPosition', + onSelect: listener + }); + + const user = userEvent.setup(); + const {getAllByText} = render(view); + await user.click(getAllByText('Move here')[0]); + + expect(listener).toHaveBeenCalledWith({ + section: entry.sections.get(1), + position: 'before' + }); + }); + + it('allows selecting section part in sectionPart mode', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [ + {id: 1, permaId: 10} + ] + }) + }); + const listener = jest.fn(); + const view = new SelectMoveDestinationDialogView({ + entry, + mode: 'sectionPart', + onSelect: listener + }); + + const user = userEvent.setup(); + const {getByText} = render(view); + await user.click(getByText('Move to beginning')); + + expect(listener).toHaveBeenCalledWith({ + section: entry.sections.get(1), + part: 'beginning' + }); + }); + + it('closes when section is selected', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [ + {id: 1, permaId: 10} + ] + }) + }); + const view = new SelectMoveDestinationDialogView({ + entry, + mode: 'insertPosition', + onSelect: jest.fn() + }); + view.close = jest.fn(); + + const user = userEvent.setup(); + const {getAllByText} = render(view); + await user.click(getAllByText('Move here')[0]); + + expect(view.close).toHaveBeenCalled(); + }); + + it('closes when cancel button is clicked', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 1}] + }) + }); + const view = new SelectMoveDestinationDialogView({ + entry, + mode: 'insertPosition', + onSelect: jest.fn() + }); + view.close = jest.fn(); + + const user = userEvent.setup(); + const {getByRole} = render(view); + await user.click(getByRole('button', {name: 'Cancel'})); + + expect(view.close).toHaveBeenCalled(); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js b/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js new file mode 100644 index 000000000..b279b1cb1 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js @@ -0,0 +1,259 @@ +import {SelectableEntryOutlineView} from 'editor/views/SelectableEntryOutlineView'; +import {ScrolledEntry} from 'editor/models/ScrolledEntry'; + +import {factories, normalizeSeed} from 'support'; +import {useFakeTranslations, renderBackboneView as render} from 'pageflow/testHelpers'; + +import userEvent from '@testing-library/user-event'; + +describe('SelectableEntryOutlineView', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.selectable_storyline_item.blank_slate': 'No chapters', + 'pageflow_scrolled.editor.selectable_storyline_item.blank_slate_excursions': 'No excursions', + 'pageflow_scrolled.editor.selectable_chapter_item.title': 'Select chapter', + 'pageflow_scrolled.editor.selectable_chapter_item.insert_here': 'Move to chapter', + 'pageflow_scrolled.editor.selectable_section_item.title': 'Select section', + 'pageflow_scrolled.editor.selectable_section_item.insert_here': 'Move here', + 'pageflow_scrolled.editor.selectable_section_item.insert_at_beginning': 'Move to beginning', + 'pageflow_scrolled.editor.selectable_section_item.insert_at_end': 'Move to end' + }); + + describe('in default mode', () => { + it('allows selecting chapter', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + chapters: [{id: 1, permaId: 10}], + sections: [{id: 1, chapterId: 1}] + }) + }); + const listener = jest.fn(); + const view = new SelectableEntryOutlineView({ + entry, + onSelectChapter: listener, + onSelectSection: jest.fn() + }); + + const user = userEvent.setup(); + const {getByTitle} = render(view); + await user.click(getByTitle('Select chapter')); + + expect(listener).toHaveBeenCalledWith(entry.chapters.get(1)); + }); + + it('allows selecting section', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + chapters: [{id: 1, permaId: 10}], + sections: [{id: 1, permaId: 100, chapterId: 1}] + }) + }); + const listener = jest.fn(); + const view = new SelectableEntryOutlineView({ + entry, + onSelectChapter: jest.fn(), + onSelectSection: listener + }); + + const user = userEvent.setup(); + const {getByTitle} = render(view); + await user.click(getByTitle('Select section')); + + expect(listener).toHaveBeenCalledWith(entry.sections.get(1)); + }); + }); + + describe('with mode insertPosition', () => { + it('renders indicator inside upper mask', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 1, permaId: 10}] + }) + }); + const view = new SelectableEntryOutlineView({ + entry, + mode: 'insertPosition', + onSelectInsertPosition: jest.fn() + }); + + const {getAllByText} = render(view); + + expect(getAllByText('Move here').length).toBe(2); + }); + + it('clicking upper mask calls onSelectInsertPosition with position before', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [ + {id: 1, permaId: 10} + ] + }) + }); + const listener = jest.fn(); + const view = new SelectableEntryOutlineView({ + entry, + mode: 'insertPosition', + onSelectInsertPosition: listener + }); + + const user = userEvent.setup(); + const {getAllByText} = render(view); + await user.click(getAllByText('Move here')[0]); + + expect(listener).toHaveBeenCalledWith({ + section: entry.sections.get(1), + position: 'before' + }); + }); + + it('clicking lower mask calls onSelectInsertPosition with position after', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [ + {id: 1, permaId: 10} + ] + }) + }); + const listener = jest.fn(); + const view = new SelectableEntryOutlineView({ + entry, + mode: 'insertPosition', + onSelectInsertPosition: listener + }); + + const user = userEvent.setup(); + const {getAllByText} = render(view); + await user.click(getAllByText('Move here')[1]); + + expect(listener).toHaveBeenCalledWith({ + section: entry.sections.get(1), + position: 'after' + }); + }); + + it('clicking empty chapter insert mask calls onSelectInsertPosition with position into', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + chapters: [{id: 1}] + }) + }); + const listener = jest.fn(); + const view = new SelectableEntryOutlineView({ + entry, + mode: 'insertPosition', + onSelectInsertPosition: listener + }); + + const user = userEvent.setup(); + const {getByText} = render(view); + await user.click(getByText('Move to chapter')); + + expect(listener).toHaveBeenCalledWith({ + chapter: entry.chapters.get(1), + position: 'into' + }); + }); + }); + + describe('with mode sectionPart', () => { + it('renders indicator inside upper mask', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 1, permaId: 10}] + }) + }); + const view = new SelectableEntryOutlineView({ + entry, + mode: 'sectionPart', + onSelectSectionPart: jest.fn() + }); + + const {getByText} = render(view); + + expect(getByText('Move to beginning')).toBeTruthy(); + }); + + it('renders indicator inside lower mask', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 1, permaId: 10}] + }) + }); + const view = new SelectableEntryOutlineView({ + entry, + mode: 'sectionPart', + onSelectSectionPart: jest.fn() + }); + + const {getByText} = render(view); + + expect(getByText('Move to end')).toBeTruthy(); + }); + + it('clicking upper mask calls onSelectSectionPart with part beginning', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [ + {id: 1, permaId: 10} + ] + }) + }); + const listener = jest.fn(); + const view = new SelectableEntryOutlineView({ + entry, + mode: 'sectionPart', + onSelectSectionPart: listener + }); + + const user = userEvent.setup(); + const {getByText} = render(view); + await user.click(getByText('Move to beginning')); + + expect(listener).toHaveBeenCalledWith({ + section: entry.sections.get(1), + part: 'beginning' + }); + }); + + it('clicking lower mask calls onSelectSectionPart with part end', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [ + {id: 1, permaId: 10} + ] + }) + }); + const listener = jest.fn(); + const view = new SelectableEntryOutlineView({ + entry, + mode: 'sectionPart', + onSelectSectionPart: listener + }); + + const user = userEvent.setup(); + const {getByText} = render(view); + await user.click(getByText('Move to end')); + + expect(listener).toHaveBeenCalledWith({ + section: entry.sections.get(1), + part: 'end' + }); + }); + }); + + describe('blank slates', () => { + it('renders blank slate when main storyline has no chapters', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed() + }); + const view = new SelectableEntryOutlineView({ + entry, + mode: 'sectionPart', + onSelectSectionPart: jest.fn() + }); + + const {getByText} = render(view); + + expect(getByText('No chapters')).toBeTruthy(); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/computeBounds-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/computeBounds-spec.js new file mode 100644 index 000000000..068bda8a4 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/computeBounds-spec.js @@ -0,0 +1,109 @@ +/** @jsx jsx */ +import {computeBounds} from 'frontend/inlineEditing/EditableText/computeBounds'; + +import {createHyperscript} from 'slate-hyperscript'; + +const h = createHyperscript({ + elements: { + paragraph: {type: 'paragraph'}, + heading: {type: 'heading'} + }, +}); + +// Strip meta tags to make deep equality checks work +const jsx = (tagName, attributes, ...children) => { + delete attributes.__self; + delete attributes.__source; + return h(tagName, attributes, ...children); +} + +describe('computeBounds', () => { + it('returns bounds of single selected paragraph', () => { + const editor = ( + + + Line 1 + + + + Line 2 + + + ); + + expect(computeBounds(editor)).toEqual([0, 0]); + }); + + it('returns bounds of multiple selected paragraphs', () => { + const editor = ( + + + + Line 1 + + + Line 2 + + + + Line 3 + + + ); + + expect(computeBounds(editor)).toEqual([0, 1]); + }); + + it('returns bounds when selection starts in second paragraph', () => { + const editor = ( + + + Line 1 + + + + Line 2 + + + Line 3 + + + + ); + + expect(computeBounds(editor)).toEqual([1, 2]); + }); + + it('excludes paragraph when focus is at start of next paragraph', () => { + const editor = ( + + + + Line 1 + + + + Line 2 + + + Line 3 + + + ); + + expect(computeBounds(editor)).toEqual([0, 0]); + }); + + it('returns [0, 0] when no selection', () => { + const editor = ( + + + Line 1 + + + ); + editor.selection = null; + + expect(computeBounds(editor)).toEqual([0, 0]); + }); +}); diff --git a/entry_types/scrolled/package/src/contentElements/textBlock/editor.js b/entry_types/scrolled/package/src/contentElements/textBlock/editor.js index e98450233..e321cc327 100644 --- a/entry_types/scrolled/package/src/contentElements/textBlock/editor.js +++ b/entry_types/scrolled/package/src/contentElements/textBlock/editor.js @@ -145,6 +145,13 @@ editor.contentElementTypes.register('textBlock', { handleDuplicate(contentElement) { contentElement.postCommand({type: 'DUPLICATE'}); + }, + + handleMove(contentElement, to) { + contentElement.postCommand({ + type: 'MOVE_TO', + payload: {to} + }); } }); diff --git a/entry_types/scrolled/package/src/editor/models/Chapter.js b/entry_types/scrolled/package/src/editor/models/Chapter.js index 3ca9f1731..b28b32a5c 100644 --- a/entry_types/scrolled/package/src/editor/models/Chapter.js +++ b/entry_types/scrolled/package/src/editor/models/Chapter.js @@ -117,5 +117,44 @@ export const Chapter = Backbone.Model.extend({ }); return newSection; + }, + + moveSection(section, {after, before} = {}) { + const targetSection = after || before; + const sourceChapter = section.chapter; + + reindexPositions( + sectionsInNewOrder(this.sections, section, targetSection, Boolean(after)) + ); + + if (sourceChapter !== this) { + this.sections.add(section); + } + + this.sections.sort(); + this.sections.saveOrder(); + + this.entry.trigger('selectSection', section); + this.entry.trigger('scrollToSection', section); } }); + +function sectionsInNewOrder(sections, section, targetSection, after) { + const result = sections.filter(s => s !== section); + + if (!targetSection) { + result.push(section); + return result; + } + + const targetIndex = result.indexOf(targetSection); + const insertIndex = after ? targetIndex + 1 : targetIndex; + + result.splice(insertIndex, 0, section); + + return result; +} + +function reindexPositions(sections) { + sections.forEach((section, index) => section.set('position', index)); +} 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 429f40440..7ce82be2d 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -122,13 +122,15 @@ export const ScrolledEntry = Entry.extend({ moveContentElement( {id: movedId, range: movedRange}, - {id, at, splitPoint} + {id, at, splitPoint}, + {success} = {} ) { moveContentElement(this, this.contentElements.get(movedId), { range: movedRange, sibling: this.contentElements.get(id), at, - splitPoint + splitPoint, + success }); }, diff --git a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/moveContentElement.js b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/moveContentElement.js index 4b0fccabb..dd57c0968 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/moveContentElement.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/moveContentElement.js @@ -7,7 +7,7 @@ import {maybeMergeWithAdjacent} from './maybeMergeWithAdjacent'; // block). Merge content elements of the same type that become // adjacent by moving a content element away (e.g. two text blocks // surrounding an image that is moved away). -export function moveContentElement(entry, contentElement, {range, sibling, at, splitPoint}) { +export function moveContentElement(entry, contentElement, {range, sibling, at, splitPoint, success}) { const sourceBatch = new Batch(entry, contentElement.section); // If we move content elements between sections, merges will need to @@ -114,6 +114,10 @@ export function moveContentElement(entry, contentElement, {range, sibling, at, s entry.trigger('selectContentElement', contentElement, { range: targetRange }); + + if (success) { + success(); + } } }); sourceBatch.saveIfDirty(); diff --git a/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js index dd3b5a54c..0f3ce93ce 100644 --- a/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js @@ -2,6 +2,8 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; import {DestroyMenuItem} from 'pageflow/editor'; +import {SelectMoveDestinationDialogView} from '../views/SelectMoveDestinationDialogView'; + export const DuplicateContentElementMenuItem = Backbone.Model.extend({ initialize(attributes, options) { this.contentElement = options.contentElement; @@ -66,3 +68,63 @@ export const DestroyContentElementMenuItem = DestroyMenuItem.extend({ this.entry.deleteContentElement(this.contentElement); } }); + +export const MoveContentElementMenuItem = Backbone.Model.extend({ + initialize(attributes, options) { + this.contentElement = options.contentElement; + this.entry = options.entry; + this.editor = options.editor; + + const contentElementType = + this.editor.contentElementTypes.findByTypeName(this.contentElement.get('typeName')); + + this.set('label', I18n.t( + contentElementType.handleMove ? + 'pageflow_scrolled.editor.content_element_menu_items.move_selection' : + 'pageflow_scrolled.editor.content_element_menu_items.move' + )); + }, + + selected() { + const contentElement = this.contentElement; + const entry = this.entry; + const contentElementType = + this.editor.contentElementTypes.findByTypeName(contentElement.get('typeName')); + + SelectMoveDestinationDialogView.show({ + entry, + mode: 'sectionPart', + onSelect: ({section: targetSection, part}) => { + const to = getTo(targetSection, part); + + if (!to) { + return; + } + + if (contentElementType.handleMove) { + contentElementType.handleMove(contentElement, to); + } + else { + entry.moveContentElement({id: contentElement.id}, to, { + success() { + entry.trigger('scrollToSection', targetSection, { + align: part === 'beginning' ? 'nearStart' : 'nearEnd' + }); + } + }); + } + } + }); + } +}); + +function getTo(targetSection, part) { + if (part === 'beginning') { + const firstContentElement = targetSection.contentElements.first(); + return firstContentElement && {at: 'before', id: firstContentElement.id}; + } + else { + const lastContentElement = targetSection.contentElements.last(); + return lastContentElement && {at: 'after', id: lastContentElement.id}; + } +} diff --git a/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js b/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js index 35935ffe0..358586f7d 100644 --- a/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js @@ -2,6 +2,8 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; import {DestroyMenuItem} from 'pageflow/editor'; +import {SelectMoveDestinationDialogView} from '../views/SelectMoveDestinationDialogView'; + export const HideShowSectionMenuItem = Backbone.Model.extend({ initialize(attributes, {section}) { this.section = section; @@ -102,6 +104,34 @@ export const CopyPermalinkMenuItem = Backbone.Model.extend({ } }); +export const MoveSectionMenuItem = Backbone.Model.extend({ + initialize(attributes, {entry, section}) { + this.entry = entry; + this.section = section; + this.set('label', I18n.t('pageflow_scrolled.editor.section_menu_items.move')); + }, + + selected() { + const section = this.section; + + SelectMoveDestinationDialogView.show({ + entry: this.entry, + mode: 'insertPosition', + onSelect: ({section: targetSection, chapter: targetChapter, position}) => { + if (position === 'into') { + targetChapter.moveSection(section); + } + else if (position === 'before') { + targetSection.chapter.moveSection(section, {before: targetSection}); + } + else { + targetSection.chapter.moveSection(section, {after: targetSection}); + } + } + }); + } +}); + export const DestroySectionMenuItem = DestroyMenuItem.extend({ translationKeyPrefix: 'pageflow_scrolled.editor.destroy_section_menu_item', @@ -117,6 +147,7 @@ export const DestroySectionMenuItem = DestroyMenuItem.extend({ export function createSectionMenuItems({entry, section}) { return [ new DuplicateSectionMenuItem({}, {section}), + new MoveSectionMenuItem({}, {entry, section}), new InsertSectionAboveMenuItem({}, {section}), new InsertSectionBelowMenuItem({}, {section}), ...(entry.cutoff.isEnabled() ? diff --git a/entry_types/scrolled/package/src/editor/views/ChapterItemView.module.css b/entry_types/scrolled/package/src/editor/views/ChapterItemView.module.css index d9e664ffb..793f388ec 100644 --- a/entry_types/scrolled/package/src/editor/views/ChapterItemView.module.css +++ b/entry_types/scrolled/package/src/editor/views/ChapterItemView.module.css @@ -13,12 +13,16 @@ background-color: var(--ui-selection-color-lighter); } -.link { +.header { display: block; margin: 0 -10px 0 -10px; padding: 10px; } +.link { + composes: header; +} + .outlineLink { composes: chapterLink from './outline.module.css'; composes: link; diff --git a/entry_types/scrolled/package/src/editor/views/EditContentElementView.js b/entry_types/scrolled/package/src/editor/views/EditContentElementView.js index 736d8d092..c99492cef 100644 --- a/entry_types/scrolled/package/src/editor/views/EditContentElementView.js +++ b/entry_types/scrolled/package/src/editor/views/EditContentElementView.js @@ -2,7 +2,8 @@ import {EditConfigurationView} from 'pageflow/editor'; import { DestroyContentElementMenuItem, - DuplicateContentElementMenuItem + DuplicateContentElementMenuItem, + MoveContentElementMenuItem } from '../models/contentElementMenuItems'; export const EditContentElementView = EditConfigurationView.extend({ @@ -25,7 +26,12 @@ export const EditContentElementView = EditConfigurationView.extend({ entry: this.options.entry, editor: this.options.editor }), - new DestroyContentElementMenuItem({}, { + new MoveContentElementMenuItem({}, { + contentElement: this.model, + entry: this.options.entry, + editor: this.options.editor + }), + new DestroyContentElementMenuItem({separated: true}, { contentElement: this.model, entry: this.options.entry, editor: this.options.editor diff --git a/entry_types/scrolled/package/src/editor/views/SectionItemView.module.css b/entry_types/scrolled/package/src/editor/views/SectionItemView.module.css index 35d04cdc6..0b843fc56 100644 --- a/entry_types/scrolled/package/src/editor/views/SectionItemView.module.css +++ b/entry_types/scrolled/package/src/editor/views/SectionItemView.module.css @@ -30,7 +30,6 @@ margin-right: -6px; } -.selectable:hover .outline, .active .outline { border: solid selectionWidth selectionColor; } diff --git a/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.module.css b/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.module.css index 41d5eb4a6..17a936d22 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.module.css +++ b/entry_types/scrolled/package/src/editor/views/SelectLinkDestinationDialogView.module.css @@ -4,6 +4,8 @@ width: 40%; min-width: 400px; max-width: 700px; + height: 100vh; + max-height: 1000px; } .urlContainer { @@ -53,9 +55,8 @@ .outlineContainer { width: 100%; - box-sizing: border-box; - padding: space(2); - overflow: auto; + flex: 1; + container-type: size; } .fileContainer { diff --git a/entry_types/scrolled/package/src/editor/views/SelectMoveDestinationDialogView.js b/entry_types/scrolled/package/src/editor/views/SelectMoveDestinationDialogView.js new file mode 100644 index 000000000..6dfb068c6 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/SelectMoveDestinationDialogView.js @@ -0,0 +1,74 @@ +import I18n from 'i18n-js'; +import Marionette from 'backbone.marionette'; + +import {app} from 'pageflow/editor'; +import {cssModulesUtils} from 'pageflow/ui'; + +import {SelectableEntryOutlineView} from './SelectableEntryOutlineView'; +import {dialogView} from './mixins/dialogView'; +import dialogViewStyles from './mixins/dialogView.module.css'; +import styles from './SelectMoveDestinationDialogView.module.css'; + +export const SelectMoveDestinationDialogView = Marionette.ItemView.extend({ + template: (data) => ` +
+
+

${I18n.t(`pageflow_scrolled.editor.select_move_destination.header_${data.mode}`)}

+

${I18n.t('pageflow_scrolled.editor.select_move_destination.hint')}

+ +
+ +
+ +
+
+
+ `, + + ui: cssModulesUtils.ui(styles, 'outlineContainer'), + + mixins: [dialogView], + + serializeData() { + return { + mode: this.options.mode + }; + }, + + onRender() { + const outlineOptions = { + entry: this.options.entry, + mode: this.options.mode + }; + + if (this.options.mode === 'insertPosition') { + outlineOptions.onSelectInsertPosition = result => { + this.options.onSelect(result); + this.close(); + }; + } + else if (this.options.mode === 'sectionPart') { + outlineOptions.onSelectSectionPart = result => { + this.options.onSelect(result); + this.close(); + }; + } + else { + outlineOptions.onSelectSection = section => { + this.options.onSelect(section); + this.close(); + }; + } + + this.ui.outlineContainer.append( + this.subview(new SelectableEntryOutlineView(outlineOptions)).el + ); + } +}); + +SelectMoveDestinationDialogView.show = function(options) { + const view = new SelectMoveDestinationDialogView(options); + app.dialogRegion.show(view.render()); +}; diff --git a/entry_types/scrolled/package/src/editor/views/SelectMoveDestinationDialogView.module.css b/entry_types/scrolled/package/src/editor/views/SelectMoveDestinationDialogView.module.css new file mode 100644 index 000000000..91ee5a902 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/SelectMoveDestinationDialogView.module.css @@ -0,0 +1,14 @@ +.box { + display: flex; + flex-direction: column; + width: 700px; + height: 100vh; + max-height: 1000px; +} + +.outlineContainer { + flex: 1; + width: 100%; + box-sizing: border-box; + container-type: size; +} diff --git a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js index 0475b0b70..3568965c8 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js @@ -1,43 +1,84 @@ import I18n from 'i18n-js'; import Marionette from 'backbone.marionette'; +import classNames from 'classnames'; import {cssModulesUtils, CollectionView} from 'pageflow/ui'; import {SelectableSectionItemView} from './SelectableSectionItemView'; -import styles from './ChapterItemView.module.css'; +import baseStyles from './ChapterItemView.module.css'; +import styles from './SelectableChapterItemView.module.css'; export const SelectableChapterItemView = Marionette.ItemView.extend({ tagName: 'li', - className: styles.root, - template: () => ` - - - - + className() { + return classNames(baseStyles.root, styles.root, { + [styles.empty]: this.model.sections.length === 0 + }); + }, + + template: (data) => ` + ${data.selectable ? ` + + + + + ` : ` + + + + + `} + + ${data.mode === 'insertPosition' ? ` + + + ${I18n.t('pageflow_scrolled.editor.selectable_chapter_item.insert_here')} + + + ` : ''} + `, - - `, + ui: cssModulesUtils.ui(baseStyles, 'title', 'number', 'sections'), - ui: cssModulesUtils.ui(styles, 'title', 'number', 'sections'), + serializeData() { + return { + mode: this.options.mode, + selectable: this.options.mode !== 'insertPosition' && + this.options.mode !== 'sectionPart' + }; + }, - events: cssModulesUtils.events(styles, { - 'click link': function(event) { - event.preventDefault(); - return this.options.onSelectChapter(this.model); - }, + events() { + return { + ...cssModulesUtils.events(baseStyles, { + 'click link': function(event) { + event.preventDefault(); + this.options.onSelectChapter(this.model); + }, - 'mouseenter link': function() { - this.$el.addClass(styles.selectableHover); - }, + 'mouseenter link': function() { + this.$el.addClass(baseStyles.selectableHover); + }, - 'mouseleave link': function() { - this.$el.removeClass(styles.selectableHover); - } - }), + 'mouseleave link': function() { + this.$el.removeClass(baseStyles.selectableHover); + } + }), + ...cssModulesUtils.events(styles, { + 'click emptyChapterInsertMask': function(event) { + event.preventDefault(); + this.options.onSelectInsertPosition({ + chapter: this.model, + position: 'into' + }); + } + }) + }; + }, modelEvents: { change: 'update' @@ -50,7 +91,10 @@ export const SelectableChapterItemView = Marionette.ItemView.extend({ itemViewConstructor: SelectableSectionItemView, itemViewOptions: { entry: this.options.entry, - onSelect: this.options.onSelectSection + mode: this.options.mode, + onSelect: this.options.onSelectSection, + onSelectInsertPosition: this.options.onSelectInsertPosition, + onSelectSectionPart: this.options.onSelectSectionPart } })); diff --git a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css new file mode 100644 index 000000000..fd5c037b3 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css @@ -0,0 +1,48 @@ +@value insertLineWidth, insertLineColor from './insertIndicator.module.css'; + +.root { + max-width: 300px; + margin-inline: auto; + position: relative; +} + +.empty { + cursor: pointer; +} + +.emptyChapterInsertMask { + display: none; + position: absolute; + inset: 0; +} + +.empty .emptyChapterInsertMask { + display: block; +} + +.emptyChapterInsertMask::before { + content: ""; + display: none; + position: absolute; + height: insertLineWidth; + background: insertLineColor; + bottom: 10px; + left: 10px; + right: 10px; +} + +.empty:hover .emptyChapterInsertMask::before { + display: block; +} + +.indicatorTooltip { + composes: tooltip from './insertIndicator.module.css'; + display: none; + left: calc(100% - 8px); + bottom: 13px; + transform: translateY(50%); +} + +.empty:hover .indicatorTooltip { + display: block; +} diff --git a/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.js b/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.js index e5871c700..e6c2a74d2 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.js @@ -20,8 +20,11 @@ export const SelectableEntryOutlineView = Marionette.Layout.extend({ itemViewContstuctor: SelectableStorylineItemView, itemViewOptions: { entry: this.options.entry, + mode: this.options.mode, onSelectChapter: this.options.onSelectChapter, - onSelectSection: this.options.onSelectSection + onSelectSection: this.options.onSelectSection, + onSelectInsertPosition: this.options.onSelectInsertPosition, + onSelectSectionPart: this.options.onSelectSectionPart } }), {to: this.ui.tabs} diff --git a/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.module.css b/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.module.css index 7b70fc4e2..bd62b6927 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.module.css +++ b/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.module.css @@ -1,4 +1,14 @@ -.tabs { - max-width: 300px; - margin: 0 auto; +.tabs :global(.tabs_view) { + height: 100cqh; + display: flex; + flex-direction: column; +} + +.tabs :global(.tabs_view-container) { + min-height: 0; + flex: 1; + overflow: auto; + scrollbar-gutter: stable; + padding-right: space(2); + border-bottom: solid 1px var(--ui-on-surface-color-lightest); } diff --git a/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js b/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js index ab31668d1..ff8827461 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js @@ -1,35 +1,100 @@ +import classNames from 'classnames'; import I18n from 'i18n-js'; import Marionette from 'backbone.marionette'; import {cssModulesUtils} from 'pageflow/ui'; import {SectionThumbnailView} from './SectionThumbnailView'; -import styles from './SectionItemView.module.css'; +import baseStyles from './SectionItemView.module.css'; +import styles from './SelectableSectionItemView.module.css'; export const SelectableSectionItemView = Marionette.ItemView.extend({ tagName: 'li', - className: `${styles.root} ${styles.selectable}`, + + className() { + return classNames(baseStyles.root, { + [styles.selectable]: !this.options.mode + }); + }, template: (data) => ` -
-
-
- - + - `, + `, - ui: cssModulesUtils.ui(styles, 'thumbnail'), + ui: cssModulesUtils.ui(baseStyles, 'thumbnail'), - events: { - [`click .${styles.clickMask}`]: function(event) { + serializeData() { + return { + mode: this.options.mode + }; + }, + + events: cssModulesUtils.events(styles, { + [`click clickMask`]: function(event) { event.preventDefault(); this.options.onSelect(this.model); + }, + [`click insertBeforeMask`]: function(event) { + event.preventDefault(); + this.options.onSelectInsertPosition({ + section: this.model, + position: 'before' + }); + }, + [`click insertAfterMask`]: function(event) { + event.preventDefault(); + this.options.onSelectInsertPosition({ + section: this.model, + position: 'after' + }); + }, + [`click insertAtBeginningMask`]: function(event) { + event.preventDefault(); + this.options.onSelectSectionPart({ + section: this.model, + part: 'beginning' + }); + }, + [`click insertAtEndMask`]: function(event) { + event.preventDefault(); + this.options.onSelectSectionPart({ + section: this.model, + part: 'end' + }); } - }, + }), onRender() { this.subview(new SectionThumbnailView({ diff --git a/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.module.css b/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.module.css new file mode 100644 index 000000000..b4d460ae5 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.module.css @@ -0,0 +1,116 @@ +@value selectionColor from './colors.module.css'; +@value selectionWidth: 3px; +@value insertLineWidth, insertLineColor from './insertIndicator.module.css'; + +.selectable:hover .outline { + border-color: selectionColor; +} + +.clickMask { + composes: clickMask from './SectionItemView.module.css'; +} + +.mask { + position: absolute; + left: 0; + right: 0; + cursor: pointer; +} + +.upperMask { + composes: mask; + top: calc(-1 * selectionWidth - 1px); + bottom: 50%; +} + +.lowerMask { + composes: mask; + top: 50%; + bottom: calc(-1 * selectionWidth - 1px); +} + +.mask::before { + content: ""; + display: none; + position: absolute; + height: insertLineWidth; + background: insertLineColor; +} + +.mask:hover::before, +.mask:focus::before { + display: block; +} + +.indicatorTooltip { + composes: tooltip from './insertIndicator.module.css'; + display: none; +} + +.mask:hover .indicatorTooltip, +.mask:focus .indicatorTooltip { + display: block; +} + +.insertBeforeMask { + composes: upperMask; +} + +.insertBeforeMask::before { + top: calc(insertLineWidth / -2); + left: 1px; + right: 1px; +} + +.insertAfterMask { + composes: lowerMask; +} + +.insertAfterMask::before { + bottom: calc(insertLineWidth / -2); + left: 1px; + right: 1px; +} + +.indicatorTooltipTop { + composes: indicatorTooltip; + top: space(-4); +} + +.indicatorTooltipBottom { + composes: indicatorTooltip; + bottom: space(4); + transform: translateY(100%); +} + +.insertAtBeginningMask { + composes: upperMask; +} + +.insertAtBeginningMask::before { + top: space(2); + left: space(1); + right: space(1); +} + +.insertAtEndMask { + composes: lowerMask; +} + +.insertAtEndMask::before { + bottom: space(2); + left: space(1); + right: space(1); +} + +.indicatorTooltipInsideTop { + composes: indicatorTooltip; + top: space(2); + transform: translateY(-50%); +} + +.indicatorTooltipInsideBottom { + composes: indicatorTooltip; + bottom: space(2); + transform: translateY(50%); +} diff --git a/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.js b/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.js index 4f33cfd3d..6df75df3a 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.js @@ -1,9 +1,27 @@ +import I18n from 'i18n-js'; import Marionette from 'backbone.marionette'; import {cssModulesUtils, CollectionView} from 'pageflow/ui'; import {SelectableChapterItemView} from './SelectableChapterItemView'; import styles from './StorylineItemView.module.css'; +import selectableStyles from './SelectableStorylineItemView.module.css'; + +function blankSlateView(isMain) { + return Marionette.ItemView.extend({ + className: selectableStyles.blankSlate, + + template: (data) => I18n.t(data.translationKey), + + serializeData() { + return { + translationKey: isMain ? + 'pageflow_scrolled.editor.selectable_storyline_item.blank_slate' : + 'pageflow_scrolled.editor.selectable_storyline_item.blank_slate_excursions' + }; + } + }); +} export const SelectableStorylineItemView = Marionette.ItemView.extend({ template: () => ` @@ -19,9 +37,13 @@ export const SelectableStorylineItemView = Marionette.ItemView.extend({ itemViewConstructor: SelectableChapterItemView, itemViewOptions: { entry: this.options.entry, + mode: this.options.mode, onSelectChapter: this.options.onSelectChapter, - onSelectSection: this.options.onSelectSection - } + onSelectSection: this.options.onSelectSection, + onSelectInsertPosition: this.options.onSelectInsertPosition, + onSelectSectionPart: this.options.onSelectSectionPart + }, + blankSlateViewConstructor: blankSlateView(this.model.isMain()) })); } }); diff --git a/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.module.css b/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.module.css new file mode 100644 index 000000000..ea4f9f1b8 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.module.css @@ -0,0 +1,5 @@ +.blankSlate { + padding-top: space(8); + text-align: center; + color: var(--ui-on-surface-color-light); +} diff --git a/entry_types/scrolled/package/src/editor/views/insertIndicator.module.css b/entry_types/scrolled/package/src/editor/views/insertIndicator.module.css new file mode 100644 index 000000000..95025f639 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/insertIndicator.module.css @@ -0,0 +1,26 @@ +@value selectionColor from './colors.module.css'; +@value insertLineWidth: 4px; +@value insertLineColor: selectionColor; + +.tooltip { + position: absolute; + left: 100%; + margin-left: 20px; + background: var(--ui-on-surface-color-light); + border-radius: rounded(); + padding: space(2); + white-space: nowrap; + color: var(--ui-surface-color); + z-index: 1; +} + +.tooltip::before { + content: ""; + position: absolute; + left: space(-2); + top: 50%; + transform: translateY(-50%); + border: space(2) solid transparent; + border-right-color: var(--ui-on-surface-color-light); + border-left: none; +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/Selection.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/Selection.js index 8e1dad3ef..b97bb06d9 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/Selection.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/Selection.js @@ -1,5 +1,5 @@ import React, {useEffect, useRef} from 'react'; -import {Editor, Transforms, Range, Path, Node} from 'slate'; +import {Editor, Transforms, Node} from 'slate'; import {useSlate, ReactEditor} from 'slate-react'; import {useDrag} from 'react-dnd'; @@ -11,6 +11,7 @@ import {useI18n} from '../../i18n'; import {postInsertContentElementMessage} from '../postMessage'; import {getUniformSelectedNode} from './getUniformSelectedNode'; import {toggleBlock, isBlockActive} from './blocks'; +import {computeBounds} from './computeBounds'; import TextIcon from '../images/text.svg'; import HeadingIcon from '../images/heading.svg'; @@ -144,20 +145,6 @@ export function Selection(props) { ); } -function computeBounds(editor) { - const startPoint = Range.start(editor.selection); - const endPoint = Range.end(editor.selection); - - const startPath = startPoint.path.slice(0, 1); - let endPath = endPoint.path.slice(0, 1); - - if (!Path.equals(startPath, endPath) && endPoint.offset === 0) { - endPath = Path.previous(endPath); - } - - return [startPath[0], endPath[0]]; -} - function hideRect(el) { el.removeAttribute('style'); } diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/computeBounds.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/computeBounds.js new file mode 100644 index 000000000..8e8c99bed --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/computeBounds.js @@ -0,0 +1,19 @@ +import {Range, Path} from 'slate'; + +export function computeBounds(editor) { + if (!editor.selection) { + return [0, 0]; + } + + const startPoint = Range.start(editor.selection); + const endPoint = Range.end(editor.selection); + + const startPath = startPoint.path.slice(0, 1); + let endPath = endPoint.path.slice(0, 1); + + if (!Path.equals(startPath, endPath) && endPoint.offset === 0) { + endPath = Path.previous(endPath); + } + + return [startPath[0], endPath[0]]; +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js index b9aa964eb..eb55eff59 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/index.js @@ -37,6 +37,8 @@ import { import {useShortcutHandler} from './shortcuts'; import {duplicateNodes} from './duplicateNodes'; +import {computeBounds} from './computeBounds'; +import {postMoveContentElementMessage} from '../postMessage'; import styles from './index.module.css'; @@ -117,6 +119,16 @@ export const EditableText = React.memo(function EditableText({ duplicateNodes(editor); ReactEditor.focus(editor); } + else if (command.type === 'MOVE_TO') { + const {to} = command.payload; + const [start, end] = computeBounds(editor); + + postMoveContentElementMessage({ + id: contentElementId, + range: [start, end + 1], + to + }); + } else if (command.type === 'TRANSIENT_STATE_UPDATE') { if ('typographyVariant' in command.payload) { applyTypographyVariant(editor, command.payload.typographyVariant);