From d1d4e9d1d2421ab3d120e5ed7c31102df92f0331 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 14 Jan 2026 08:48:30 +0100 Subject: [PATCH 1/7] Do not scroll entry outline tabs in link selection dialog REDMINE-21205 --- ...SelectLinkDestinationDialogView.module.css | 7 +++--- .../editor/views/SelectableChapterItemView.js | 23 +++++++++++-------- .../SelectableChapterItemView.module.css | 4 ++++ .../SelectableEntryOutlineView.module.css | 16 ++++++++++--- 4 files changed, 34 insertions(+), 16 deletions(-) create mode 100644 entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css 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 41d5eb4a69..17a936d224 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/SelectableChapterItemView.js b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js index 0475b0b701..7f7cf27128 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js @@ -1,41 +1,44 @@ 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, + + className: classNames(baseStyles.root, styles.root), template: () => ` - - - + + - + `, - ui: cssModulesUtils.ui(styles, 'title', 'number', 'sections'), + ui: cssModulesUtils.ui(baseStyles, 'title', 'number', 'sections'), - events: cssModulesUtils.events(styles, { + events: cssModulesUtils.events(baseStyles, { 'click link': function(event) { event.preventDefault(); return this.options.onSelectChapter(this.model); }, 'mouseenter link': function() { - this.$el.addClass(styles.selectableHover); + this.$el.addClass(baseStyles.selectableHover); }, 'mouseleave link': function() { - this.$el.removeClass(styles.selectableHover); + this.$el.removeClass(baseStyles.selectableHover); } }), 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 0000000000..c66ee476b2 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css @@ -0,0 +1,4 @@ +.root { + max-width: 300px; + margin-inline: auto; +} 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 7b70fc4e25..bd62b6927b 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); } From b956f93b951685f52548bc8464c57549021c8cf7 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 14 Jan 2026 08:48:30 +0100 Subject: [PATCH 2/7] Add moveSection method to Chapter REDMINE-21205 --- .../spec/editor/models/Chapter-spec.js | 152 ++++++++++++++++++ .../package/src/editor/models/Chapter.js | 33 ++++ 2 files changed, 185 insertions(+) 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 332170cc43..19491fbc3a 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,158 @@ 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('#isExcursion', () => { it('returns false for chapters in main storyline', () => { const entry = factories.entry(ScrolledEntry, {}, { diff --git a/entry_types/scrolled/package/src/editor/models/Chapter.js b/entry_types/scrolled/package/src/editor/models/Chapter.js index 3ca9f17311..f9aa52fa28 100644 --- a/entry_types/scrolled/package/src/editor/models/Chapter.js +++ b/entry_types/scrolled/package/src/editor/models/Chapter.js @@ -117,5 +117,38 @@ 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); + 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)); +} From 25d847a15f7a9891b15b46b37040c5fb4d9eaf40 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 14 Jan 2026 08:48:30 +0100 Subject: [PATCH 3/7] Add move dialog for sections and content elements Allows moving sections and content elements to different positions via a dialog that displays the entry outline with clickable targets. REDMINE-21205 --- entry_types/scrolled/config/locales/de.yml | 11 + entry_types/scrolled/config/locales/en.yml | 11 + .../models/contentElementMenuItems-spec.js | 135 ++++++++++- .../editor/models/sectionMenuItems-spec.js | 81 ++++++- .../SelectMoveDestinationDialogView-spec.js | 146 ++++++++++++ .../views/SelectableEntryOutlineView-spec.js | 216 ++++++++++++++++++ .../editor/models/contentElementMenuItems.js | 43 ++++ .../src/editor/models/sectionMenuItems.js | 28 +++ .../editor/views/ChapterItemView.module.css | 6 +- .../editor/views/EditContentElementView.js | 8 +- .../editor/views/SectionItemView.module.css | 1 - .../views/SelectMoveDestinationDialogView.js | 74 ++++++ ...SelectMoveDestinationDialogView.module.css | 14 ++ .../editor/views/SelectableChapterItemView.js | 40 +++- .../views/SelectableEntryOutlineView.js | 5 +- .../editor/views/SelectableSectionItemView.js | 93 ++++++-- .../SelectableSectionItemView.module.css | 135 +++++++++++ .../views/SelectableStorylineItemView.js | 5 +- 18 files changed, 1019 insertions(+), 33 deletions(-) create mode 100644 entry_types/scrolled/package/spec/editor/views/SelectMoveDestinationDialogView-spec.js create mode 100644 entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js create mode 100644 entry_types/scrolled/package/src/editor/views/SelectMoveDestinationDialogView.js create mode 100644 entry_types/scrolled/package/src/editor/views/SelectMoveDestinationDialogView.module.css create mode 100644 entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.module.css diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 580a534809..bfda59ffe9 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1161,6 +1161,8 @@ de: duplicate_content_element_menu_item: label: Element duplizieren selection_label: Auswahl duplizieren + content_element_menu_items: + move: Element verschieben... destroy_section_menu_item: confirm_destroy: Abschnitt inklusive aller Elemente wirklich löschen? destroy: Abschnitt löschen @@ -1298,6 +1300,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 +1323,18 @@ 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_chapter_item: title: Kapitel auswählen 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 dc5dba9587..fac0470be7 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1145,6 +1145,8 @@ en: duplicate_content_element_menu_item: label: Duplicate element selection_label: Duplicate selection + content_element_menu_items: + move: Move element... destroy_section_menu_item: confirm_destroy: Really delete this section including all its elements? destroy: Delete section @@ -1282,6 +1284,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 +1307,18 @@ 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_chapter_item: title: Select chapter 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/contentElementMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js index 098a95b17c..341cf6f686 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,126 @@ describe('ContentElementMenuItems', () => { expect(entry.deleteContentElement).not.toHaveBeenCalled(); }); }); + + describe('MoveContentElementMenuItem', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.content_element_menu_items.move': 'Move...' + }); + + 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('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 of selected section', () => { + 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 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} + ); + }); + + it('moves content element to end of selected section', () => { + 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 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} + ); + }); + }); }); 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 b6cc1285b6..97295967af 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,74 @@ 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}); + }); + }); }); 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 0000000000..35495c9fa5 --- /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 0000000000..12e1c2397b --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js @@ -0,0 +1,216 @@ +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_chapter_item.title': 'Select 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' + }); + }); + }); + + 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' + }); + }); + }); +}); diff --git a/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js index dd3b5a54c9..1538930387 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,44 @@ 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; + this.set('label', I18n.t('pageflow_scrolled.editor.content_element_menu_items.move')); + }, + + selected() { + const contentElement = this.contentElement; + const entry = this.entry; + + SelectMoveDestinationDialogView.show({ + entry, + mode: 'sectionPart', + onSelect: ({section: targetSection, part}) => { + if (part === 'beginning') { + const firstContentElement = targetSection.contentElements.first(); + + if (firstContentElement) { + entry.moveContentElement( + {id: contentElement.id}, + {at: 'before', id: firstContentElement.id} + ); + } + } + else { + const lastContentElement = targetSection.contentElements.last(); + + if (lastContentElement) { + entry.moveContentElement( + {id: contentElement.id}, + {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 35935ffe04..163086c0a9 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,31 @@ 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, position}) => { + 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 +144,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 d9e664ffb2..793f388ecf 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 736d8d092a..835d16eff5 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,6 +26,11 @@ export const EditContentElementView = EditConfigurationView.extend({ entry: this.options.entry, editor: this.options.editor }), + new MoveContentElementMenuItem({}, { + contentElement: this.model, + entry: this.options.entry, + editor: this.options.editor + }), new DestroyContentElementMenuItem({}, { contentElement: this.model, entry: this.options.entry, 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 35d04cdc69..0b843fc56d 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/SelectMoveDestinationDialogView.js b/entry_types/scrolled/package/src/editor/views/SelectMoveDestinationDialogView.js new file mode 100644 index 0000000000..6dfb068c64 --- /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 0000000000..91ee5a9026 --- /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 7f7cf27128..f1ea5f4367 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js @@ -14,23 +14,36 @@ export const SelectableChapterItemView = Marionette.ItemView.extend({ className: classNames(baseStyles.root, styles.root), - template: () => ` - - - - - -
    - `, + template: (data) => ` + ${data.selectable ? ` + + + + + ` : ` + + + + + `} +
      + `, ui: cssModulesUtils.ui(baseStyles, 'title', 'number', 'sections'), + serializeData() { + return { + selectable: this.options.mode !== 'insertPosition' && + this.options.mode !== 'sectionPart' + }; + }, + events: cssModulesUtils.events(baseStyles, { 'click link': function(event) { event.preventDefault(); - return this.options.onSelectChapter(this.model); + this.options.onSelectChapter(this.model); }, 'mouseenter link': function() { @@ -53,7 +66,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/SelectableEntryOutlineView.js b/entry_types/scrolled/package/src/editor/views/SelectableEntryOutlineView.js index e5871c700d..e6c2a74d20 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/SelectableSectionItemView.js b/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.js index ab31668d1d..ff88274616 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 0000000000..28cb00908a --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.module.css @@ -0,0 +1,135 @@ +@value selectionColor from './colors.module.css'; +@value selectionWidth: 3px; +@value insertLineWidth: 4px; + +.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: selectionColor; +} + +.mask:hover::before, +.mask:focus::before { + display: block; +} + +.indicatorTooltip { + display: none; + 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; +} + +.indicatorTooltip::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; +} + +.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 4f33cfd3d5..900f95e1b9 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.js @@ -19,8 +19,11 @@ 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 } })); } From 1b6091a5d3fbf8ec09cf695c345af102be3655a4 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 14 Jan 2026 16:45:35 +0100 Subject: [PATCH 4/7] Support moving text block selection to different section Add handleMove to textBlock content element type to allow moving selected text ranges (not just the entire element) to a different section. When handleMove is defined on a content element type, MoveContentElementMenuItem shows "Move selection..." label and delegates to handleMove instead of entry.moveContentElement. The MOVE_TO command handler in EditableText computes the bounds of the current selection and reuses existing postMoveContentElementMessage infrastructure. Extract computeBounds from Selection.js to shared module to remove duplication. Redmine: 21205 --- entry_types/scrolled/config/locales/de.yml | 1 + entry_types/scrolled/config/locales/en.yml | 1 + .../models/contentElementMenuItems-spec.js | 57 ++++++++- .../EditableText/computeBounds-spec.js | 109 ++++++++++++++++++ .../src/contentElements/textBlock/editor.js | 7 ++ .../editor/models/contentElementMenuItems.js | 49 +++++--- .../inlineEditing/EditableText/Selection.js | 17 +-- .../EditableText/computeBounds.js | 19 +++ .../inlineEditing/EditableText/index.js | 12 ++ 9 files changed, 238 insertions(+), 34 deletions(-) create mode 100644 entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/computeBounds-spec.js create mode 100644 entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/computeBounds.js diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index bfda59ffe9..3bba30ac69 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1163,6 +1163,7 @@ de: 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 diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index fac0470be7..8f25a4ce9e 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1147,6 +1147,7 @@ en: 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 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 341cf6f686..2058ce1640 100644 --- a/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js @@ -254,7 +254,8 @@ describe('ContentElementMenuItems', () => { describe('MoveContentElementMenuItem', () => { useFakeTranslations({ - 'pageflow_scrolled.editor.content_element_menu_items.move': 'Move...' + 'pageflow_scrolled.editor.content_element_menu_items.move': 'Move...', + 'pageflow_scrolled.editor.content_element_menu_items.move_selection': 'Move selection...' }); beforeEach(() => { @@ -280,6 +281,25 @@ describe('ContentElementMenuItems', () => { 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, {}, { @@ -372,5 +392,40 @@ describe('ContentElementMenuItems', () => { {at: 'after', id: 3} ); }); + + 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/frontend/inlineEditing/EditableText/computeBounds-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/computeBounds-spec.js new file mode 100644 index 0000000000..068bda8a45 --- /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 e98450233a..e321cc3271 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/contentElementMenuItems.js b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js index 1538930387..1575480090 100644 --- a/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js @@ -74,38 +74,51 @@ export const MoveContentElementMenuItem = Backbone.Model.extend({ this.contentElement = options.contentElement; this.entry = options.entry; this.editor = options.editor; - this.set('label', I18n.t('pageflow_scrolled.editor.content_element_menu_items.move')); + + 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}) => { - if (part === 'beginning') { - const firstContentElement = targetSection.contentElements.first(); - - if (firstContentElement) { - entry.moveContentElement( - {id: contentElement.id}, - {at: 'before', id: firstContentElement.id} - ); - } + const to = getTo(targetSection, part); + + if (!to) { + return; + } + + if (contentElementType.handleMove) { + contentElementType.handleMove(contentElement, to); } else { - const lastContentElement = targetSection.contentElements.last(); - - if (lastContentElement) { - entry.moveContentElement( - {id: contentElement.id}, - {at: 'after', id: lastContentElement.id} - ); - } + entry.moveContentElement({id: contentElement.id}, to); } } }); } }); + +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/frontend/inlineEditing/EditableText/Selection.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/Selection.js index 8e1dad3efa..b97bb06d9c 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 0000000000..8e8c99bedd --- /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 b9aa964eb8..eb55eff59f 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); From c11eb2ff7084d7412023c9914b02b47d76424293 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 14 Jan 2026 17:07:21 +0100 Subject: [PATCH 5/7] Show blank slate in move destination dialog Display a helpful message when a storyline has no chapters, so users understand why no move destinations are available. Redmine: 21205 --- entry_types/scrolled/config/locales/de.yml | 3 +++ entry_types/scrolled/config/locales/en.yml | 3 +++ .../views/SelectableEntryOutlineView-spec.js | 19 +++++++++++++++++ .../views/SelectableStorylineItemView.js | 21 ++++++++++++++++++- .../SelectableStorylineItemView.module.css | 5 +++++ 5 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.module.css diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 3bba30ac69..bf0e08f2cb 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1329,6 +1329,9 @@ de: 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 selectable_section_item: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 8f25a4ce9e..112489db9a 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1313,6 +1313,9 @@ en: 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 selectable_section_item: diff --git a/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js b/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js index 12e1c2397b..be019168b6 100644 --- a/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js @@ -8,6 +8,8 @@ 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_section_item.title': 'Select section', 'pageflow_scrolled.editor.selectable_section_item.insert_here': 'Move here', @@ -213,4 +215,21 @@ describe('SelectableEntryOutlineView', () => { }); }); }); + + 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/src/editor/views/SelectableStorylineItemView.js b/entry_types/scrolled/package/src/editor/views/SelectableStorylineItemView.js index 900f95e1b9..6df75df3ab 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: () => ` @@ -24,7 +42,8 @@ export const SelectableStorylineItemView = Marionette.ItemView.extend({ 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 0000000000..ea4f9f1b8b --- /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); +} From 7a50229c52c0dfbe92316f88d258c48adf8ce9b5 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 14 Jan 2026 17:27:54 +0100 Subject: [PATCH 6/7] Allow moving sections into empty chapters Adds clickable insert target to empty chapters in the move destination dialog. Extracts shared tooltip and insert line styles to a reusable CSS module. REDMINE-21205 --- entry_types/scrolled/config/locales/de.yml | 1 + entry_types/scrolled/config/locales/en.yml | 1 + .../spec/editor/models/Chapter-spec.js | 44 ++++++++++++++++ .../editor/models/sectionMenuItems-spec.js | 20 ++++++++ .../views/SelectableEntryOutlineView-spec.js | 24 +++++++++ .../package/src/editor/models/Chapter.js | 8 ++- .../src/editor/models/sectionMenuItems.js | 7 ++- .../editor/views/SelectableChapterItemView.js | 51 ++++++++++++++----- .../SelectableChapterItemView.module.css | 44 ++++++++++++++++ .../SelectableSectionItemView.module.css | 25 ++------- .../editor/views/insertIndicator.module.css | 26 ++++++++++ 11 files changed, 213 insertions(+), 38 deletions(-) create mode 100644 entry_types/scrolled/package/src/editor/views/insertIndicator.module.css diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index bf0e08f2cb..49e6c63b0b 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1334,6 +1334,7 @@ de: 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 diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 112489db9a..e7bc604fe2 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1318,6 +1318,7 @@ en: 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 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 19491fbc3a..fa24dad7b0 100644 --- a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js @@ -414,6 +414,50 @@ describe('Chapter', () => { 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', () => { 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 97295967af..e46b8861f5 100644 --- a/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js @@ -337,5 +337,25 @@ describe('SectionMenuItems', () => { 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/SelectableEntryOutlineView-spec.js b/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js index be019168b6..b279b1cb17 100644 --- a/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SelectableEntryOutlineView-spec.js @@ -11,6 +11,7 @@ describe('SelectableEntryOutlineView', () => { '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', @@ -128,6 +129,29 @@ describe('SelectableEntryOutlineView', () => { 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', () => { diff --git a/entry_types/scrolled/package/src/editor/models/Chapter.js b/entry_types/scrolled/package/src/editor/models/Chapter.js index f9aa52fa28..b28b32a5c1 100644 --- a/entry_types/scrolled/package/src/editor/models/Chapter.js +++ b/entry_types/scrolled/package/src/editor/models/Chapter.js @@ -119,7 +119,7 @@ export const Chapter = Backbone.Model.extend({ return newSection; }, - moveSection(section, {after, before}) { + moveSection(section, {after, before} = {}) { const targetSection = after || before; const sourceChapter = section.chapter; @@ -141,6 +141,12 @@ export const Chapter = Backbone.Model.extend({ 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; diff --git a/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js b/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js index 163086c0a9..358586f7d5 100644 --- a/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js @@ -117,8 +117,11 @@ export const MoveSectionMenuItem = Backbone.Model.extend({ SelectMoveDestinationDialogView.show({ entry: this.entry, mode: 'insertPosition', - onSelect: ({section: targetSection, position}) => { - if (position === 'before') { + onSelect: ({section: targetSection, chapter: targetChapter, position}) => { + if (position === 'into') { + targetChapter.moveSection(section); + } + else if (position === 'before') { targetSection.chapter.moveSection(section, {before: targetSection}); } else { diff --git a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js index f1ea5f4367..3568965c8a 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.js @@ -12,7 +12,11 @@ import styles from './SelectableChapterItemView.module.css'; export const SelectableChapterItemView = Marionette.ItemView.extend({ tagName: 'li', - className: classNames(baseStyles.root, styles.root), + className() { + return classNames(baseStyles.root, styles.root, { + [styles.empty]: this.model.sections.length === 0 + }); + }, template: (data) => ` ${data.selectable ? ` @@ -29,31 +33,52 @@ export const SelectableChapterItemView = Marionette.ItemView.extend({ `}
        + ${data.mode === 'insertPosition' ? ` + + + ${I18n.t('pageflow_scrolled.editor.selectable_chapter_item.insert_here')} + + + ` : ''} `, ui: cssModulesUtils.ui(baseStyles, 'title', 'number', 'sections'), serializeData() { return { + mode: this.options.mode, selectable: this.options.mode !== 'insertPosition' && this.options.mode !== 'sectionPart' }; }, - events: cssModulesUtils.events(baseStyles, { - 'click link': function(event) { - event.preventDefault(); - 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(baseStyles.selectableHover); - }, + 'mouseenter link': function() { + this.$el.addClass(baseStyles.selectableHover); + }, - 'mouseleave link': function() { - this.$el.removeClass(baseStyles.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' diff --git a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css index c66ee476b2..fd5c037b30 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css +++ b/entry_types/scrolled/package/src/editor/views/SelectableChapterItemView.module.css @@ -1,4 +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/SelectableSectionItemView.module.css b/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.module.css index 28cb00908a..b4d460ae53 100644 --- a/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.module.css +++ b/entry_types/scrolled/package/src/editor/views/SelectableSectionItemView.module.css @@ -1,6 +1,6 @@ @value selectionColor from './colors.module.css'; @value selectionWidth: 3px; -@value insertLineWidth: 4px; +@value insertLineWidth, insertLineColor from './insertIndicator.module.css'; .selectable:hover .outline { border-color: selectionColor; @@ -34,7 +34,7 @@ display: none; position: absolute; height: insertLineWidth; - background: selectionColor; + background: insertLineColor; } .mask:hover::before, @@ -43,27 +43,8 @@ } .indicatorTooltip { + composes: tooltip from './insertIndicator.module.css'; display: none; - 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; -} - -.indicatorTooltip::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; } .mask:hover .indicatorTooltip, 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 0000000000..95025f6390 --- /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; +} From fa7e0b5caec0b1aa252fdc4b5f7e9668615aff6c Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 15 Jan 2026 08:56:25 +0100 Subject: [PATCH 7/7] Scroll to target section after moving content element When moving a content element via the menu action, the preview now scrolls to show the target location once the move completes. Uses 'nearStart' alignment when moving to beginning and 'nearEnd' when moving to end of section. REDMINE-21205 --- .../ScrolledEntry/moveContentElement-spec.js | 18 +++++++++++++++ .../models/contentElementMenuItems-spec.js | 22 +++++++++++++++---- .../src/editor/models/ScrolledEntry/index.js | 6 +++-- .../ScrolledEntry/moveContentElement.js | 6 ++++- .../editor/models/contentElementMenuItems.js | 8 ++++++- .../editor/views/EditContentElementView.js | 2 +- 6 files changed, 53 insertions(+), 9 deletions(-) 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 cc051e1257..8344ff7a11 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 2058ce1640..d014a6dea9 100644 --- a/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js @@ -325,7 +325,7 @@ describe('ContentElementMenuItems', () => { }); }); - it('moves content element to beginning of selected section', () => { + it('moves content element to beginning and scrolls on success', () => { const editor = factories.editorApi(); const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ @@ -341,6 +341,8 @@ describe('ContentElementMenuItems', () => { 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, @@ -355,11 +357,16 @@ describe('ContentElementMenuItems', () => { expect(entry.moveContentElement).toHaveBeenCalledWith( {id: 1}, - {at: 'before', id: 2} + {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 of selected section', () => { + it('moves content element to end and scrolls on success', () => { const editor = factories.editorApi(); const entry = factories.entry(ScrolledEntry, {}, { entryTypeSeed: normalizeSeed({ @@ -375,6 +382,8 @@ describe('ContentElementMenuItems', () => { 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, @@ -389,8 +398,13 @@ describe('ContentElementMenuItems', () => { expect(entry.moveContentElement).toHaveBeenCalledWith( {id: 1}, - {at: 'after', id: 3} + {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', () => { 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 429f40440f..7ce82be2d4 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 4b0fccabb1..dd57c09688 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 1575480090..0f3ce93ce1 100644 --- a/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js @@ -105,7 +105,13 @@ export const MoveContentElementMenuItem = Backbone.Model.extend({ contentElementType.handleMove(contentElement, to); } else { - entry.moveContentElement({id: contentElement.id}, to); + entry.moveContentElement({id: contentElement.id}, to, { + success() { + entry.trigger('scrollToSection', targetSection, { + align: part === 'beginning' ? 'nearStart' : 'nearEnd' + }); + } + }); } } }); diff --git a/entry_types/scrolled/package/src/editor/views/EditContentElementView.js b/entry_types/scrolled/package/src/editor/views/EditContentElementView.js index 835d16eff5..c99492cef4 100644 --- a/entry_types/scrolled/package/src/editor/views/EditContentElementView.js +++ b/entry_types/scrolled/package/src/editor/views/EditContentElementView.js @@ -31,7 +31,7 @@ export const EditContentElementView = EditConfigurationView.extend({ entry: this.options.entry, editor: this.options.editor }), - new DestroyContentElementMenuItem({}, { + new DestroyContentElementMenuItem({separated: true}, { contentElement: this.model, entry: this.options.entry, editor: this.options.editor