From adb7a3c78752dc25242de1bd95f85310a461d9d6 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 27 Oct 2026 14:12:32 +0100 Subject: [PATCH 01/15] Show copy cursor over content elements while selection modifier is held Give feedback that clicking adds a content element to the selection before the click happens. The preview usually does not have keyboard focus while the pointer is above it, so the modifier state is also read from mouse moves. --- .../contentElementMultiSelection-spec.js | 14 +++++ .../inlineEditing/multiSelection-spec.js | 56 ++++++++++++++++++- .../ContentElementDecorator.module.css | 5 ++ .../frontend/inlineEditing/EntryDecorator.js | 3 + .../frontend/inlineEditing/multiSelection.js | 22 ++++++++ 5 files changed, 99 insertions(+), 1 deletion(-) diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementMultiSelection-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementMultiSelection-spec.js index 650817a3b9..4a0181e6f0 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementMultiSelection-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementMultiSelection-spec.js @@ -11,6 +11,20 @@ import '@testing-library/jest-dom/extend-expect' describe('inline editing content element multi selection', () => { useInlineEditingPageObjects(); + it('flags document while selection modifier is held', () => { + renderEntry({ + seed: { + contentElements: [{typeName: 'withTestId', configuration: {testId: 5}}] + } + }); + + fireEvent.keyDown(window, {key: 'Control', ctrlKey: true}); + + expect(document.documentElement).toHaveAttribute('data-selection-modifier'); + + fireEvent.keyUp(window, {key: 'Control'}); + }); + it('marks both content elements as selected', () => { const {getContentElementByTestId} = renderEntry({ seed: { diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/multiSelection-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/multiSelection-spec.js index 287a1e8a7f..79fff6b2e3 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/multiSelection-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/multiSelection-spec.js @@ -1,6 +1,7 @@ import { extendMultiSelection, - isInMultiSelection + isInMultiSelection, + watchSelectionModifier } from 'frontend/inlineEditing/multiSelection'; describe('extendMultiSelection', () => { @@ -128,3 +129,56 @@ describe('isInMultiSelection', () => { expect(isInMultiSelection(null, {type: 'contentElement', id: 6})).toBe(false); }); }); + +describe('watchSelectionModifier', () => { + let unwatch; + + afterEach(() => unwatch()); + + function isFlagged() { + return document.documentElement.hasAttribute('data-selection-modifier'); + } + + it('flags document while modifier key is held', () => { + unwatch = watchSelectionModifier(window); + + window.dispatchEvent(new KeyboardEvent('keydown', {key: 'Meta', metaKey: true})); + + expect(isFlagged()).toBe(true); + }); + + it('removes flag when modifier key is released', () => { + unwatch = watchSelectionModifier(window); + + window.dispatchEvent(new KeyboardEvent('keydown', {key: 'Control', ctrlKey: true})); + window.dispatchEvent(new KeyboardEvent('keyup', {key: 'Control'})); + + expect(isFlagged()).toBe(false); + }); + + it('reads modifier state from mouse moves', () => { + unwatch = watchSelectionModifier(window); + + window.dispatchEvent(new MouseEvent('mousemove', {ctrlKey: true})); + + expect(isFlagged()).toBe(true); + }); + + it('removes flag when window loses focus', () => { + unwatch = watchSelectionModifier(window); + + window.dispatchEvent(new MouseEvent('mousemove', {metaKey: true})); + window.dispatchEvent(new Event('blur')); + + expect(isFlagged()).toBe(false); + }); + + it('removes flag and stops watching when unwatched', () => { + watchSelectionModifier(window)(); + unwatch = () => {}; + + window.dispatchEvent(new MouseEvent('mousemove', {metaKey: true})); + + expect(isFlagged()).toBe(false); + }); +}); diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.module.css b/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.module.css index d5b080d286..2280dad77d 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.module.css +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.module.css @@ -1,3 +1,8 @@ .wrapper { position: relative; } + +:global([data-selection-modifier]) .wrapper, +:global([data-selection-modifier]) .wrapper * { + cursor: copy !important; +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EntryDecorator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EntryDecorator.js index 475043419b..638c17c359 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EntryDecorator.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EntryDecorator.js @@ -8,6 +8,7 @@ import { import {useEntryStateDispatch} from 'pageflow-scrolled/entryState'; import {usePostMessageListener} from '../../shared/usePostMessageListener'; import {EditorStateProvider, useEditorSelection} from './EditorState'; +import {watchSelectionModifier} from './multiSelection'; import { useContentElementEditorCommandEmitter, ContentElementEditorCommandSubscriptionProvider @@ -16,6 +17,8 @@ import { export function EntryDecorator({commentingInitialState, children}) { const contentElementEditorCommandEmitter = useContentElementEditorCommandEmitter(); + useEffect(() => watchSelectionModifier(window), []); + return ( diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/multiSelection.js b/entry_types/scrolled/package/src/frontend/inlineEditing/multiSelection.js index 884d33b5ae..495242c87a 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/multiSelection.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/multiSelection.js @@ -10,6 +10,28 @@ export function hasSelectionModifier(event) { return event.ctrlKey || event.metaKey; } +export function watchSelectionModifier(window) { + const root = window.document.documentElement; + const events = ['keydown', 'keyup', 'mousemove']; + + function update(event) { + root.toggleAttribute('data-selection-modifier', hasSelectionModifier(event)); + } + + function reset() { + root.removeAttribute('data-selection-modifier'); + } + + events.forEach(name => window.addEventListener(name, update)); + window.addEventListener('blur', reset); + + return () => { + events.forEach(name => window.removeEventListener(name, update)); + window.removeEventListener('blur', reset); + reset(); + }; +} + export function extendMultiSelection(selection, {type, id}) { if (isMultiSelectionOf(selection, type)) { return isInMultiSelection(selection, {type, id}) ? From db48092e2fa092e33fdfa42879927046895df7f4 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 29 Oct 2026 10:48:36 +0100 Subject: [PATCH 02/15] Extract selected sections into fragment library Fragments of sections could only be created from all sections of a chapter. Let the editor pass an arbitrary set of sections, which are copied in document order into a new fragment chapter of the shared library. --- ...section_fragment_extractions_controller.rb | 36 +++++ .../pageflow_scrolled/fragment_library.rb | 10 ++ entry_types/scrolled/config/routes.rb | 1 + ...on_fragment_extractions_controller_spec.rb | 128 ++++++++++++++++++ .../fragment_library_spec.rb | 49 +++++++ 5 files changed, 224 insertions(+) create mode 100644 entry_types/scrolled/app/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller.rb create mode 100644 entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller_spec.rb diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller.rb new file mode 100644 index 0000000000..0bc9dfa4f0 --- /dev/null +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller.rb @@ -0,0 +1,36 @@ +module PageflowScrolled + module Editor + # @api private + class SectionFragmentExtractionsController < ActionController::Base + include Pageflow::EditorController + + before_action do + head :not_found unless @entry.feature_state('fragments') + end + + def create + library = FragmentLibrary.shared_for_account(@entry.account) + + authorize!(:update, library) + library.extract_sections_from(entry: @entry, + sections: find_sections, + title: params[:title]) + + head :created + rescue ActiveRecord::RecordNotFound + head :not_found + end + + private + + def find_sections + ids = Array(params[:section_ids]).map(&:to_i).uniq + sections = Section.all_for_revision(@entry.draft).where(id: ids).to_a + + raise ActiveRecord::RecordNotFound if ids.empty? || sections.size < ids.size + + sections + end + end + end +end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb b/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb index 871aabfa32..33f2c5d9c5 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb @@ -67,6 +67,16 @@ def extract_fragment_from(entry:, chapter:, title: nil) end end + def extract_sections_from(entry:, sections:, title: nil) + ActiveRecord::Base.transaction do + @entry ||= create_entry(locale: entry.locale) + + SectionsCopy + .new(source_entry: entry, destination_entry: self.entry) + .perform(sections, chapter: create_chapter({'title' => title.presence&.strip}.compact)) + end + end + def extract_content_elements_from(entry:, content_elements:, title: nil) ActiveRecord::Base.transaction do @entry ||= create_entry(locale: entry.locale) diff --git a/entry_types/scrolled/config/routes.rb b/entry_types/scrolled/config/routes.rb index 4f2b954db0..bb5b41c5cb 100644 --- a/entry_types/scrolled/config/routes.rb +++ b/entry_types/scrolled/config/routes.rb @@ -3,6 +3,7 @@ resources :fragment_libraries, only: [:index] resources :content_element_fragment_extractions, only: [:create] resources :content_element_fragment_preparations, only: [:create] + resources :section_fragment_extractions, only: [:create] resources :linkable_entries, only: [:index, :show] resources :trash_items, only: [:index, :destroy] do member do diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller_spec.rb new file mode 100644 index 0000000000..d816e2047b --- /dev/null +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller_spec.rb @@ -0,0 +1,128 @@ +require 'spec_helper' +require 'pageflow/editor_controller_test_helper' + +module PageflowScrolled + RSpec.describe Editor::SectionFragmentExtractionsController, type: :controller do + include Pageflow::EditorControllerTestHelper + + routes { PageflowScrolled::Engine.routes } + + describe '#create' do + it 'requires authentication' do + entry = create(:entry, type_name: 'scrolled') + section = create(:section, revision: entry.draft) + + post_extraction(entry:, sections: [section]) + + expect(response.status).to eq(401) + end + + it 'copies sections into fragment of library' do + user = create(:user) + account = create(:account, with_editor: user) + entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') + section = create(:section, revision: entry.draft, configuration: {'layout' => 'left'}) + + sign_in_and_lock(entry, user) + post_extraction(entry:, sections: [section], title: 'Intro') + + fragment = Chapter.all_for_revision(find_library(account).draft).first + expect(response.status).to eq(201) + expect(fragment.configuration).to eq('title' => 'Intro') + expect(fragment.sections.map(&:configuration)).to match([include('layout' => 'left')]) + end + + it 'copies sections in document order' do + user = create(:user) + account = create(:account, with_editor: user) + entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') + first_chapter = create(:scrolled_chapter, revision: entry.draft, position: 0) + second_chapter = create(:scrolled_chapter, revision: entry.draft, position: 1) + second_section = create(:section, chapter: second_chapter, + configuration: {'layout' => 'right'}) + first_section = create(:section, chapter: first_chapter, + configuration: {'layout' => 'left'}) + + sign_in_and_lock(entry, user) + post_extraction(entry:, sections: [second_section, first_section]) + + expect(Section.all_for_revision(find_library(account).draft) + .map { |section| section.configuration['layout'] }) + .to eq(%w[left right]) + end + + it 'does not let user who may not edit library add fragment' do + user = create(:user) + account = create(:account, with_previewer: user) + entry = create(:entry, type_name: 'scrolled', account:, with_editor: user, + with_feature: 'fragments') + section = create(:section, revision: entry.draft) + + sign_in_and_lock(entry, user) + post_extraction(entry:, sections: [section]) + + expect(response.status).to eq(403) + expect(find_library(account)).to be_nil + end + + it 'responds with not found for section of other entry' do + user = create(:user) + account = create(:account, with_editor: user) + entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') + other_entry = create(:entry, type_name: 'scrolled', account:) + section = create(:section, revision: entry.draft) + other_section = create(:section, revision: other_entry.draft) + + sign_in_and_lock(entry, user) + post_extraction(entry:, sections: [section, other_section]) + + expect(response.status).to eq(404) + expect(find_library(account)).to be_nil + end + + it 'responds with not found without sections' do + user = create(:user) + account = create(:account, with_editor: user) + entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') + + sign_in_and_lock(entry, user) + post_extraction(entry:, sections: []) + + expect(response.status).to eq(404) + expect(find_library(account)).to be_nil + end + + it 'responds with not found if fragments feature is disabled' do + user = create(:user) + account = create(:account, with_editor: user) + entry = create(:entry, type_name: 'scrolled', account:) + section = create(:section, revision: entry.draft) + + sign_in_and_lock(entry, user) + post_extraction(entry:, sections: [section]) + + expect(response.status).to eq(404) + expect(find_library(account)).to be_nil + end + end + + def find_library(account) + Pageflow::Entry.find_by(account:, fragment_library: 'shared') + end + + def post_extraction(entry:, sections:, title: nil) + post(:create, + params: { + entry_type: 'scrolled', + entry_id: entry, + section_ids: sections.map(&:id), + title: + }.compact, format: 'json') + end + + def sign_in_and_lock(entry, user) + sign_in(user, scope: :user) + acquire_edit_lock(user, entry) + end + end +end diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb index d9fd708486..cdcbeba625 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb @@ -239,6 +239,55 @@ module PageflowScrolled end end + describe '#extract_sections_from' do + it 'creates library entry on first extraction' do + account = create(:account) + entry = create(:entry, type_name: 'scrolled', account:) + section = create(:section, revision: entry.draft) + library = FragmentLibrary.shared_for_account(account) + + library.extract_sections_from(entry: Pageflow::DraftEntry.new(entry), sections: [section]) + + expect(library).to be_persisted + expect(library.entry.to_model).to have_attributes(account:, + fragment_library: 'shared') + end + + it 'copies sections into chapter with title' do + account = create(:account) + entry = create(:entry, type_name: 'scrolled', account:) + chapter = create(:scrolled_chapter, revision: entry.draft) + first_section = create(:section, chapter:, position: 0, + configuration: {'layout' => 'left'}) + second_section = create(:section, chapter:, position: 1, + configuration: {'layout' => 'right'}) + create(:content_element, :heading, section: second_section) + library = FragmentLibrary.shared_for_account(account) + + library.extract_sections_from(entry: Pageflow::DraftEntry.new(entry), + sections: [first_section, second_section], + title: ' Intro ') + + expect(library.content.chapters.map(&:configuration)).to eq([{'title' => 'Intro'}]) + expect(library.content.sections.map(&:configuration)) + .to match([include('layout' => 'left'), include('layout' => 'right')]) + expect(library.content.content_elements.map(&:type_name)).to eq(%w[heading]) + end + + it 'omits title if blank title is given' do + account = create(:account) + entry = create(:entry, type_name: 'scrolled', account:) + section = create(:section, revision: entry.draft) + library = FragmentLibrary.shared_for_account(account) + + library.extract_sections_from(entry: Pageflow::DraftEntry.new(entry), + sections: [section], + title: ' ') + + expect(library.content.chapters.first.configuration).to eq({}) + end + end + describe '#extract_content_elements_from' do it 'creates library entry on first extraction' do account = create(:account) From 775ecd1c1ecb8008b3062cdd90aaed8185075422 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 29 Oct 2026 10:50:26 +0100 Subject: [PATCH 03/15] Add single sections to shared fragments Offer an item in the section menu that copies the section into a new fragment of the shared library. The item takes a list of sections, so it can be reused once several sections can be selected. It shares dialog, states and error handling with the item that adds content elements to the shared fragments via a common base. --- entry_types/scrolled/config/locales/de.yml | 6 + entry_types/scrolled/config/locales/en.yml | 6 + .../extractSectionsToFragmentLibrary-spec.js | 56 +++++++ .../editor/models/sectionMenuItems-spec.js | 149 +++++++++++++++++- .../editor/models/ExtractFragmentMenuItem.js | 38 +++++ .../src/editor/models/ScrolledEntry/index.js | 11 ++ .../editor/models/contentElementMenuItems.js | 42 +---- .../src/editor/models/sectionMenuItems.js | 23 ++- 8 files changed, 293 insertions(+), 38 deletions(-) create mode 100644 entry_types/scrolled/package/spec/editor/models/ScrolledEntry/extractSectionsToFragmentLibrary-spec.js create mode 100644 entry_types/scrolled/package/src/editor/models/ExtractFragmentMenuItem.js diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 3ea840235c..dcad2c05c7 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1467,6 +1467,12 @@ de: name: Name des Fragments shared_hint: Fragmente werden mit allen Beiträgen des Kontos und allen Mitgliedern mit Bearbeitungsrechten geteilt. submit: Hinzufügen + extract_sections_fragment_menu_item: + extracted: Zu geteilten Fragmenten hinzugefügt + extracting: Wird zu geteilten Fragmenten hinzugefügt... + failed: Die Abschnitte konnten nicht zu den geteilten Fragmenten hinzugefügt werden. + forbidden: Sie dürfen den geteilten Fragmenten dieses Kontos keine Abschnitte hinzufügen. + label: Zu geteilten Fragmenten hinzufügen... fragment_library: blank_slate: Noch keine Fragmente vorhanden. cancel: Abbrechen diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index d88e419a4a..d82f5113e1 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1449,6 +1449,12 @@ en: name: Name of the fragment shared_hint: Fragments are shared across all entries of the account and all its members with editor access. submit: Add + extract_sections_fragment_menu_item: + extracted: Added to shared fragments + extracting: Adding to shared fragments... + failed: The sections could not be added to the shared fragments. + forbidden: You are not allowed to add sections to the shared fragments of this account. + label: Add to shared fragments... fragment_library: blank_slate: No fragments yet. cancel: Cancel diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/extractSectionsToFragmentLibrary-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/extractSectionsToFragmentLibrary-spec.js new file mode 100644 index 0000000000..a73caa0efd --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/extractSectionsToFragmentLibrary-spec.js @@ -0,0 +1,56 @@ +import {ScrolledEntry} from 'editor/models/ScrolledEntry'; + +import {setupGlobals} from 'pageflow/testHelpers'; +import {factories, normalizeSeed, useFakeXhr} from 'support'; + +describe('ScrolledEntry#extractSectionsToFragmentLibrary', () => { + let testContext; + + beforeEach(() => { + testContext = {}; + testContext.entry = factories.entry(ScrolledEntry, {id: 1}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 10}, {id: 11}] + }) + }); + }); + + setupGlobals({ + entry: () => testContext.entry + }); + + useFakeXhr(() => testContext); + + it('posts to section fragment extractions of entry', () => { + const {entry, requests} = testContext; + + entry.extractSectionsToFragmentLibrary([entry.sections.get(10)]); + + expect(requests[0].method).toBe('POST'); + expect(requests[0].url) + .toBe('/editor/entries/1/scrolled/section_fragment_extractions'); + }); + + it('posts ids of sections and title', () => { + const {entry, requests} = testContext; + + entry.extractSectionsToFragmentLibrary([entry.sections.get(10), entry.sections.get(11)], + {title: 'Intro'}); + + expect(new URLSearchParams(requests[0].requestBody).getAll('section_ids[]')) + .toEqual(['10', '11']); + expect(new URLSearchParams(requests[0].requestBody).get('title')).toEqual('Intro'); + }); + + it('resolves once library responds without body', async () => { + const {entry, server} = testContext; + + const extraction = entry.extractSectionsToFragmentLibrary([entry.sections.get(10)]); + server.respond( + 'POST', '/editor/entries/1/scrolled/section_fragment_extractions', + [201, {}, ''] + ); + + await expect(extraction).resolves.toBeUndefined(); + }); +}); 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 e46b8861f5..9ff82877db 100644 --- a/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/sectionMenuItems-spec.js @@ -6,11 +6,15 @@ import { CutoffSectionMenuItem, CopyPermalinkMenuItem, DestroySectionMenuItem, - MoveSectionMenuItem + MoveSectionMenuItem, + ExtractSectionsFragmentMenuItem, + createSectionMenuItems } from 'editor/models/sectionMenuItems'; import {SelectMoveDestinationDialogView} from 'editor/views/SelectMoveDestinationDialogView'; +import {ExtractFragmentDialogView} from 'editor/views/ExtractFragmentDialogView'; -import {useFakeTranslations} from 'pageflow/testHelpers'; +import {app} from 'pageflow/editor'; +import {useFakeFeatures, useFakeTranslations} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; jest.mock('editor/views/SelectMoveDestinationDialogView', () => ({ @@ -31,7 +35,17 @@ describe('SectionMenuItems', () => { '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?' + 'pageflow_scrolled.editor.destroy_section_menu_item.confirm_destroy': 'Really delete this section?', + 'pageflow_scrolled.editor.extract_sections_fragment_menu_item.label': + 'Add to shared fragments', + 'pageflow_scrolled.editor.extract_sections_fragment_menu_item.extracting': + 'Adding to shared fragments...', + 'pageflow_scrolled.editor.extract_sections_fragment_menu_item.extracted': + 'Added to shared fragments', + 'pageflow_scrolled.editor.extract_sections_fragment_menu_item.failed': + 'Could not add sections.', + 'pageflow_scrolled.editor.extract_sections_fragment_menu_item.forbidden': + 'Not allowed.' }); const {createEntry} = useEditorGlobals(); @@ -358,4 +372,133 @@ describe('SectionMenuItems', () => { expect(targetChapter.moveSection).toHaveBeenCalledWith(section); }); }); + + describe('ExtractSectionsFragmentMenuItem', () => { + beforeEach(() => { + jest.spyOn(ExtractFragmentDialogView, 'show').mockImplementation(() => {}); + }); + + afterEach(() => jest.restoreAllMocks()); + + function createMenuItem(extraction) { + const entry = createEntry({sections: [{id: 1}, {id: 2}]}); + entry.extractSectionsToFragmentLibrary = jest.fn().mockReturnValue(extraction); + const sections = [entry.sections.get(1), entry.sections.get(2)]; + + return {entry, sections, menuItem: new ExtractSectionsFragmentMenuItem({}, {entry, sections})}; + } + + function pendingExtraction() { + return new Promise(() => {}); + } + + function submitName(name) { + return ExtractFragmentDialogView.show.mock.calls[0][0].onSubmit(name); + } + + it('has Add to shared fragments label', () => { + const {menuItem} = createMenuItem(pendingExtraction()); + + expect(menuItem.get('label')).toBe('Add to shared fragments'); + }); + + it('extracts sections with submitted name', () => { + const {entry, sections, menuItem} = createMenuItem(pendingExtraction()); + + menuItem.selected(); + submitName('Intro'); + + expect(entry.extractSectionsToFragmentLibrary) + .toHaveBeenCalledWith(sections, {title: 'Intro'}); + }); + + it('is disabled while extraction is pending', () => { + const {menuItem} = createMenuItem(pendingExtraction()); + + menuItem.selected(); + submitName('Intro'); + + expect(menuItem.get('label')).toBe('Adding to shared fragments...'); + expect(menuItem.get('disabled')).toBe(true); + }); + + it('stays disabled once sections have been added', async () => { + const {menuItem} = createMenuItem(Promise.resolve()); + + menuItem.selected(); + await submitName('Intro'); + + expect(menuItem.get('label')).toBe('Added to shared fragments'); + expect(menuItem.get('disabled')).toBe(true); + }); + + it('is enabled again and reports error when extraction fails', async () => { + const {menuItem} = createMenuItem(Promise.reject({status: 500})); + const errors = []; + app.on('error', error => errors.push(error.message)); + + menuItem.selected(); + await submitName('Intro'); + + expect(menuItem.get('label')).toBe('Add to shared fragments'); + expect(menuItem.get('disabled')).toBe(false); + expect(errors).toEqual(['Could not add sections.']); + app.off('error'); + }); + + it('reports missing permission', async () => { + const {menuItem} = createMenuItem(Promise.reject({status: 403})); + const errors = []; + app.on('error', error => errors.push(error.message)); + + menuItem.selected(); + await submitName('Intro'); + + expect(errors).toEqual(['Not allowed.']); + app.off('error'); + }); + }); + + describe('createSectionMenuItems', () => { + it('does not offer adding section to shared fragments by default', () => { + const entry = createEntry({sections: [{id: 1}]}); + + const items = createSectionMenuItems({entry, section: entry.sections.get(1)}); + + expect(items.map(item => item.get('label'))).not.toContain('Add to shared fragments'); + }); + + it('separates copy permalink item by default', () => { + const entry = createEntry({sections: [{id: 1}]}); + + const items = createSectionMenuItems({entry, section: entry.sections.get(1)}); + + expect(items.find(item => item.get('label') === 'Copy permalink').get('separated')) + .toBe(true); + }); + + describe('with fragments feature', () => { + useFakeFeatures('editor', ['fragments']); + + it('offers adding section to shared fragments', () => { + const entry = createEntry({sections: [{id: 1}]}); + + const items = createSectionMenuItems({entry, section: entry.sections.get(1)}); + const item = items.find(item => item.get('label') === 'Add to shared fragments'); + + expect(item.sections).toEqual([entry.sections.get(1)]); + }); + + it('separates adding to shared fragments instead of copy permalink item', () => { + const entry = createEntry({sections: [{id: 1}]}); + + const items = createSectionMenuItems({entry, section: entry.sections.get(1)}); + + expect(items.find(item => item.get('label') === 'Add to shared fragments') + .get('separated')).toBe(true); + expect(items.find(item => item.get('label') === 'Copy permalink').get('separated')) + .toBeFalsy(); + }); + }); + }); }); diff --git a/entry_types/scrolled/package/src/editor/models/ExtractFragmentMenuItem.js b/entry_types/scrolled/package/src/editor/models/ExtractFragmentMenuItem.js new file mode 100644 index 0000000000..fbff97055c --- /dev/null +++ b/entry_types/scrolled/package/src/editor/models/ExtractFragmentMenuItem.js @@ -0,0 +1,38 @@ +import Backbone from 'backbone'; +import I18n from 'i18n-js'; +import {app} from 'pageflow/editor'; + +import {ExtractFragmentDialogView} from '../views/ExtractFragmentDialogView'; + +export const ExtractFragmentMenuItem = Backbone.Model.extend({ + initialize() { + this.update('label', {disabled: false}); + }, + + selected() { + ExtractFragmentDialogView.show({ + onSubmit: title => this.extract(title) + }); + }, + + extract(title) { + this.update('extracting', {disabled: true}); + + return this.extractToFragmentLibrary({title}).then( + () => this.update('extracted', {disabled: true}), + ({status}) => { + this.update('label', {disabled: false}); + app.trigger('error', { + message: I18n.t(`${this.translationKeyPrefix}.${status === 403 ? 'forbidden' : 'failed'}`) + }); + } + ); + }, + + update(labelKey, {disabled}) { + this.set({ + label: I18n.t(`${this.translationKeyPrefix}.${labelKey}`), + disabled + }); + } +}); 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 0887c4daab..3d4536ad49 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -238,6 +238,17 @@ export const ScrolledEntry = Entry.extend({ })).then(() => undefined); }, + extractSectionsToFragmentLibrary(sections, {title} = {}) { + return Promise.resolve($.ajax({ + url: `${this.url()}/scrolled/section_fragment_extractions`, + type: 'POST', + data: { + section_ids: sections.map(section => section.id), + title + } + })).then(() => undefined); + }, + fileReferences() { return this.fileReferencesIndex; }, diff --git a/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js index 4ca89e0988..f2d74786bd 100644 --- a/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js @@ -1,8 +1,8 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; -import {app, DestroyMenuItem} from 'pageflow/editor'; +import {DestroyMenuItem} from 'pageflow/editor'; -import {ExtractFragmentDialogView} from '../views/ExtractFragmentDialogView'; +import {ExtractFragmentMenuItem} from './ExtractFragmentMenuItem'; import {SelectMoveDestinationDialogView} from '../views/SelectMoveDestinationDialogView'; export const DuplicateContentElementMenuItem = Backbone.Model.extend({ @@ -86,43 +86,17 @@ export const DestroyContentElementsMenuItem = DestroyMenuItem.extend({ } }); -export const ExtractContentElementsFragmentMenuItem = Backbone.Model.extend({ +export const ExtractContentElementsFragmentMenuItem = ExtractFragmentMenuItem.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.extract_content_elements_fragment_menu_item', + initialize(attributes, {entry, items}) { this.entry = entry; this.items = items; - this.update('label', {disabled: false}); - }, - - selected() { - ExtractFragmentDialogView.show({ - onSubmit: title => this.extract(title) - }); + ExtractFragmentMenuItem.prototype.initialize.call(this); }, - extract(title) { - this.update('extracting', {disabled: true}); - - return this.entry.extractContentElementsToFragmentLibrary(this.items, {title}).then( - () => this.update('extracted', {disabled: true}), - ({status}) => { - this.update('label', {disabled: false}); - app.trigger('error', { - message: I18n.t( - 'pageflow_scrolled.editor.extract_content_elements_fragment_menu_item.' + - (status === 403 ? 'forbidden' : 'failed') - ) - }); - } - ); - }, - - update(labelKey, {disabled}) { - this.set({ - label: I18n.t( - `pageflow_scrolled.editor.extract_content_elements_fragment_menu_item.${labelKey}` - ), - disabled - }); + extractToFragmentLibrary(options) { + return this.entry.extractContentElementsToFragmentLibrary(this.items, options); } }); diff --git a/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js b/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js index 358586f7d5..cc3b2b5245 100644 --- a/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/sectionMenuItems.js @@ -1,7 +1,9 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; import {DestroyMenuItem} from 'pageflow/editor'; +import {features} from 'pageflow/frontend'; +import {ExtractFragmentMenuItem} from './ExtractFragmentMenuItem'; import {SelectMoveDestinationDialogView} from '../views/SelectMoveDestinationDialogView'; export const HideShowSectionMenuItem = Backbone.Model.extend({ @@ -132,6 +134,20 @@ export const MoveSectionMenuItem = Backbone.Model.extend({ } }); +export const ExtractSectionsFragmentMenuItem = ExtractFragmentMenuItem.extend({ + translationKeyPrefix: 'pageflow_scrolled.editor.extract_sections_fragment_menu_item', + + initialize(attributes, {entry, sections}) { + this.entry = entry; + this.sections = sections; + ExtractFragmentMenuItem.prototype.initialize.call(this); + }, + + extractToFragmentLibrary(options) { + return this.entry.extractSectionsToFragmentLibrary(this.sections, options); + } +}); + export const DestroySectionMenuItem = DestroyMenuItem.extend({ translationKeyPrefix: 'pageflow_scrolled.editor.destroy_section_menu_item', @@ -145,6 +161,8 @@ export const DestroySectionMenuItem = DestroyMenuItem.extend({ }); export function createSectionMenuItems({entry, section}) { + const fragments = features.isEnabled('fragments'); + return [ new DuplicateSectionMenuItem({}, {section}), new MoveSectionMenuItem({}, {entry, section}), @@ -153,7 +171,10 @@ export function createSectionMenuItems({entry, section}) { ...(entry.cutoff.isEnabled() ? [new CutoffSectionMenuItem({}, {cutoff: entry.cutoff, section})] : []), - new CopyPermalinkMenuItem({separated: true}, {entry, section}), + ...(fragments ? + [new ExtractSectionsFragmentMenuItem({separated: true}, {entry, sections: [section]})] : + []), + new CopyPermalinkMenuItem({separated: !fragments}, {entry, section}), new HideShowSectionMenuItem({separated: true}, {section}), new DestroySectionMenuItem({}, {section}) ]; From 97383f6818e5febfc5d890e1f123bada886655ac Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 30 Oct 2026 10:37:38 +0100 Subject: [PATCH 04/15] Extract mark button view Let other views reuse the round check button of the files explorer including its Font Awesome icons and selection handling. Add ListSelection#isActive to tell whether check buttons need to be offered and skip events for calls of start and stop that change nothing. --- .../stylesheets/pageflow/editor/base.scss | 1 + .../pageflow/editor/files_gallery.scss | 34 +----- .../pageflow/editor/mark_button.scss | 36 ++++++ package/documentation.yml | 1 + .../editor/collections/ListSelection-spec.js | 56 +++++++++ .../spec/editor/views/MarkButtonView-spec.js | 110 ++++++++++++++++++ .../src/editor/collections/ListSelection.js | 12 ++ package/src/editor/index.js | 1 + .../src/editor/templates/explorerFileItem.jst | 2 - .../src/editor/views/ExplorerFileItemView.js | 52 ++++----- package/src/editor/views/MarkButtonView.js | 68 +++++++++++ 11 files changed, 311 insertions(+), 62 deletions(-) create mode 100644 app/assets/stylesheets/pageflow/editor/mark_button.scss create mode 100644 package/spec/editor/views/MarkButtonView-spec.js create mode 100644 package/src/editor/views/MarkButtonView.js diff --git a/app/assets/stylesheets/pageflow/editor/base.scss b/app/assets/stylesheets/pageflow/editor/base.scss index cd217cca92..86fbab4e36 100644 --- a/app/assets/stylesheets/pageflow/editor/base.scss +++ b/app/assets/stylesheets/pageflow/editor/base.scss @@ -71,6 +71,7 @@ @import "./files"; @import "./confirm_encoding"; @import "./files_gallery"; + @import "./mark_button"; @import "./filtered_files"; @import "./file_type_pills"; @import "./folder_breadcrumb"; diff --git a/app/assets/stylesheets/pageflow/editor/files_gallery.scss b/app/assets/stylesheets/pageflow/editor/files_gallery.scss index 96cafafb70..3c3cfd500b 100644 --- a/app/assets/stylesheets/pageflow/editor/files_gallery.scss +++ b/app/assets/stylesheets/pageflow/editor/files_gallery.scss @@ -40,13 +40,7 @@ ul.files_gallery { background-color: var(--ui-selection-color-lighter); } - > li:hover .explorer_file_item-mark, - .explorer_file_item-mark:focus-visible { - opacity: 0.8; - } - - > li.marking .explorer_file_item-mark { - @include fa-circle-icon; + > li:hover .mark_button:not(.marked) { opacity: 0.8; } @@ -54,35 +48,11 @@ ul.files_gallery { background-color: transparent; } - > li.marking .explorer_file_item-mark:hover { - @include fa-check-circle-icon; - } - - > li.marked .explorer_file_item-mark { - @include fa-check-circle-icon; - - opacity: 1; - } - // Top right corner of the 100px wide thumbnail centered in the item. - .explorer_file_item-mark { - @include fa-check-circle-icon; - + .mark_button { position: absolute; top: space(3); left: calc(50% + 50px - #{space(7)}); - width: space(6); - height: space(6); - padding: 0; - border: none; - background-color: transparent; - color: var(--ui-on-primary-color); - filter: drop-shadow(0 0 2px #00000077); - font-size: 22px; - font-weight: bold; - line-height: space(6); - cursor: pointer; - opacity: 0; } > li.blank_slate, diff --git a/app/assets/stylesheets/pageflow/editor/mark_button.scss b/app/assets/stylesheets/pageflow/editor/mark_button.scss new file mode 100644 index 0000000000..bd24dc0db9 --- /dev/null +++ b/app/assets/stylesheets/pageflow/editor/mark_button.scss @@ -0,0 +1,36 @@ +.mark_button { + @include fa-check-circle-icon; + + width: space(6); + height: space(6); + padding: 0; + border: none; + background-color: transparent; + color: var(--ui-on-primary-color); + filter: drop-shadow(0 0 2px #00000077); + font-size: 22px; + font-weight: bold; + line-height: space(6); + cursor: pointer; + opacity: 0; + + &:focus-visible { + opacity: 0.8; + } + + &.marking { + @include fa-circle-icon; + + opacity: 0.8; + } + + &.marking:hover { + @include fa-check-circle-icon; + } + + &.marked { + @include fa-check-circle-icon; + + opacity: 1; + } +} diff --git a/package/documentation.yml b/package/documentation.yml index d859d55e2b..43dbc2d19e 100644 --- a/package/documentation.yml +++ b/package/documentation.yml @@ -60,6 +60,7 @@ toc: - DropDownButtonView - DestroyMenuItem - ListView + - MarkButtonView - ModelThumbnailView - name: UI - Input Views diff --git a/package/spec/editor/collections/ListSelection-spec.js b/package/spec/editor/collections/ListSelection-spec.js index 0d3bd5f450..b7adb09546 100644 --- a/package/spec/editor/collections/ListSelection-spec.js +++ b/package/spec/editor/collections/ListSelection-spec.js @@ -47,6 +47,62 @@ describe('ListSelection', () => { expect(selection.models).toEqual([file]); }); + describe('#isActive', () => { + it('is false by default', () => { + const selection = new ListSelection(); + + expect(selection.isActive()).toBe(false); + }); + + it('is true while items are selected', () => { + const selection = new ListSelection([f.file({id: 1})]); + + expect(selection.isActive()).toBe(true); + }); + + it('is true after start while no items are selected', () => { + const selection = new ListSelection(); + + selection.start(); + + expect(selection.isActive()).toBe(true); + }); + + it('is false after stop', () => { + const selection = new ListSelection([f.file({id: 1})]); + + selection.start(); + selection.stop(); + + expect(selection.isActive()).toBe(false); + }); + }); + + describe('#start', () => { + it('does not trigger event when already started', () => { + const selection = new ListSelection(); + const listener = jest.fn(); + selection.start(); + selection.on('change:selecting', listener); + + selection.start(); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('#stop', () => { + it('does not trigger events when not active', () => { + const selection = new ListSelection(); + const listener = jest.fn(); + selection.on('all', listener); + + selection.stop(); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + describe('#destroyAll', () => { it('destroys every selected item', () => { const file = f.file({id: 1}); diff --git a/package/spec/editor/views/MarkButtonView-spec.js b/package/spec/editor/views/MarkButtonView-spec.js new file mode 100644 index 0000000000..4a61bdffd4 --- /dev/null +++ b/package/spec/editor/views/MarkButtonView-spec.js @@ -0,0 +1,110 @@ +import {MarkButtonView, ListSelection} from 'pageflow/editor'; +import Backbone from 'backbone'; +import {renderBackboneView as render} from 'pageflow/testHelpers'; +import {within} from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom/extend-expect'; + +describe('MarkButtonView', () => { + function setup({selection = new ListSelection(), ...options} = {}) { + const model = new Backbone.Model(); + const view = new MarkButtonView({model, selection, label: 'Mark', ...options}); + + render(view); + + return {view, model, selection, ...within(view.el.parentNode)}; + } + + it('renders unpressed button', () => { + const {getByRole} = setup(); + + expect(getByRole('button', {name: 'Mark'})).toHaveAttribute('aria-pressed', 'false'); + }); + + it('marks model on click', async () => { + const user = userEvent.setup(); + const {model, selection, getByRole} = setup(); + + await user.click(getByRole('button', {name: 'Mark'})); + + expect(selection.models).toEqual([model]); + expect(getByRole('button', {name: 'Mark'})).toHaveAttribute('aria-pressed', 'true'); + expect(getByRole('button', {name: 'Mark'})).toHaveClass('marked'); + }); + + it('unmarks marked model on click', async () => { + const user = userEvent.setup(); + const {model, selection, getByRole} = setup(); + selection.add(model); + + await user.click(getByRole('button', {name: 'Mark'})); + + expect(selection.length).toBe(0); + expect(getByRole('button', {name: 'Mark'})).not.toHaveClass('marked'); + }); + + it('triggers toggle event after selection changed', async () => { + const user = userEvent.setup(); + const {view, model, selection, getByRole} = setup(); + const listener = jest.fn(() => expect(selection.includes(model)).toBe(true)); + view.on('toggle', listener); + + await user.click(getByRole('button', {name: 'Mark'})); + + expect(listener).toHaveBeenCalled(); + }); + + it('does not let click reach parent elements', async () => { + const user = userEvent.setup(); + const {view, getByRole} = setup(); + const listener = jest.fn(); + view.el.parentNode.addEventListener('click', listener); + + await user.click(getByRole('button', {name: 'Mark'})); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('indicates marking while other models are marked', () => { + const {getByRole} = setup({selection: new ListSelection([new Backbone.Model()])}); + + expect(getByRole('button', {name: 'Mark'})).toHaveClass('marking'); + }); + + it('indicates marking while selection is active', () => { + const {selection, getByRole} = setup(); + + selection.start(); + + expect(getByRole('button', {name: 'Mark'})).toHaveClass('marking'); + }); + + it('does not indicate marking by default', () => { + const {getByRole} = setup(); + + expect(getByRole('button', {name: 'Mark'})).not.toHaveClass('marking'); + }); + + it('updates marked state when selection is reset', () => { + const {model, selection, getByRole} = setup(); + selection.add(model); + + selection.reset(); + + expect(getByRole('button', {name: 'Mark'})).not.toHaveClass('marked'); + }); + + it('uses start marking label while selection is not active', () => { + const {getByRole} = setup({startMarkingLabel: 'Start'}); + + expect(getByRole('button', {name: 'Start'})).toHaveAttribute('title', 'Start'); + }); + + it('uses label while selection is active', () => { + const {selection, getByRole} = setup({startMarkingLabel: 'Start'}); + + selection.start(); + + expect(getByRole('button', {name: 'Mark'})).toHaveAttribute('title', 'Mark'); + }); +}); diff --git a/package/src/editor/collections/ListSelection.js b/package/src/editor/collections/ListSelection.js index 6434e6cf09..854b0ec703 100644 --- a/package/src/editor/collections/ListSelection.js +++ b/package/src/editor/collections/ListSelection.js @@ -9,11 +9,19 @@ export const ListSelection = Backbone.Collection.extend({ // even while nothing is checked. Unchecking the last item would // otherwise pull the check boxes away from under the pointer. start: function() { + if (this.selecting) { + return; + } + this.selecting = true; this.trigger('change:selecting'); }, stop: function() { + if (!this.isActive()) { + return; + } + this.selecting = false; this.reset(); this.trigger('change:selecting'); @@ -23,6 +31,10 @@ export const ListSelection = Backbone.Collection.extend({ return !!this.selecting; }, + isActive: function() { + return this.isSelecting() || !!this.length; + }, + toggle: function(item) { if (this.includes(item)) { this.remove(item); diff --git a/package/src/editor/index.js b/package/src/editor/index.js index 5c0f2b56c7..d6b8cf04cf 100644 --- a/package/src/editor/index.js +++ b/package/src/editor/index.js @@ -101,6 +101,7 @@ export * from './views/UploaderView'; export * from './views/BackgroundPositioningView'; export * from './views/BackgroundPositioningImageView'; export * from './views/ExplorerFileItemView'; +export * from './views/MarkButtonView'; export * from './views/ExplorerFileDetailsView'; export * from './views/ExplorerFilesView'; export * from './views/ExplorerFolderItemView'; diff --git a/package/src/editor/templates/explorerFileItem.jst b/package/src/editor/templates/explorerFileItem.jst index 160b6f6ec3..fd5daa5d59 100644 --- a/package/src/editor/templates/explorerFileItem.jst +++ b/package/src/editor/templates/explorerFileItem.jst @@ -1,5 +1,3 @@ - - diff --git a/package/src/editor/views/ExplorerFileItemView.js b/package/src/editor/views/ExplorerFileItemView.js index de2ab78c98..ec612405de 100644 --- a/package/src/editor/views/ExplorerFileItemView.js +++ b/package/src/editor/views/ExplorerFileItemView.js @@ -2,6 +2,7 @@ import I18n from 'i18n-js'; import Marionette from 'backbone.marionette'; import {FileThumbnailView} from './FileThumbnailView'; +import {MarkButtonView} from './MarkButtonView'; import {loadable} from './mixins/loadable'; import {selectableView} from './mixins/selectableView'; @@ -17,21 +18,10 @@ export const ExplorerFileItemView = Marionette.ItemView.extend({ ui: { fileName: '.file_name', - thumbnail: '.file_thumbnail', - markButton: '.explorer_file_item-mark' + thumbnail: '.file_thumbnail' }, events: { - 'click .explorer_file_item-mark': function() { - this.options.markedFiles.toggle(this.model); - - if (this.options.markedFiles.includes(this.model)) { - this.select(); - } - - return false; - }, - 'click': function() { if (!this.isDisabled()) { this.select(); @@ -53,14 +43,31 @@ export const ExplorerFileItemView = Marionette.ItemView.extend({ this.update(); this.updateMarked(); - if (this.isDisabled() || !this.options.markedFiles) { - this.ui.markButton.remove(); - } - this.subview(new FileThumbnailView({ el: this.ui.thumbnail, model: this.model })); + + if (!this.isDisabled() && this.options.markedFiles) { + this.appendMarkButton(); + } + }, + + appendMarkButton: function() { + var markButton = new MarkButtonView({ + model: this.model, + selection: this.options.markedFiles, + label: I18n.t('pageflow.editor.views.explorer_file_item_view.mark'), + startMarkingLabel: I18n.t('pageflow.editor.views.explorer_file_item_view.start_marking') + }); + + this.appendSubview(markButton); + + this.listenTo(markButton, 'toggle', function() { + if (this.options.markedFiles.includes(this.model)) { + this.select(); + } + }); }, update: function() { @@ -77,19 +84,8 @@ export const ExplorerFileItemView = Marionette.ItemView.extend({ }, updateMarked: function() { - var marked = !!this.options.markedFiles?.includes(this.model); - this.$el.toggleClass('marking', this.isMarking()); - this.$el.toggleClass('marked', marked); - var label = I18n.t(this.isMarking() ? - 'pageflow.editor.views.explorer_file_item_view.mark' : - 'pageflow.editor.views.explorer_file_item_view.start_marking'); - - this.ui.markButton.attr({ - 'aria-pressed': marked ? 'true' : 'false', - 'aria-label': label, - title: label - }); + this.$el.toggleClass('marked', !!this.options.markedFiles?.includes(this.model)); }, isDisabled: function() { diff --git a/package/src/editor/views/MarkButtonView.js b/package/src/editor/views/MarkButtonView.js new file mode 100644 index 0000000000..738307a195 --- /dev/null +++ b/package/src/editor/views/MarkButtonView.js @@ -0,0 +1,68 @@ +import Marionette from 'backbone.marionette'; + +/** + * Round check button that adds a model to or removes it from a + * {@link ListSelection}. + * + * Has class `marking` while the selection is active and + * class `marked` while the model is included. Triggers a `toggle` + * event after the selection changed. + * + * @param {Object} options + * + * @param {Backbone.Model} options.model + * + * @param {ListSelection} options.selection + * + * @param {String} options.label + * Accessible label and title. + * + * @param {String} [options.startMarkingLabel] + * Label to use instead while the selection is not active. + * + * @class + */ +export const MarkButtonView = Marionette.ItemView.extend({ + tagName: 'button', + className: 'mark_button', + template: () => '', + + attributes: { + type: 'button' + }, + + events: { + 'click': function() { + this.options.selection.toggle(this.model); + this.trigger('toggle'); + + return false; + } + }, + + initialize: function() { + this.listenTo(this.options.selection, 'add remove reset change:selecting', this.update); + }, + + onRender: function() { + this.update(); + }, + + update: function() { + var selection = this.options.selection; + var marking = selection.isActive(); + var marked = selection.includes(this.model); + var label = marking ? + this.options.label : + this.options.startMarkingLabel || this.options.label; + + this.$el.toggleClass('marking', marking); + this.$el.toggleClass('marked', marked); + + this.$el.attr({ + 'aria-pressed': marked ? 'true' : 'false', + 'aria-label': label, + title: label + }); + } +}); From 27af02f4fb4c1cd081e0a6f1ea3a62c96f2f5a0b Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 30 Oct 2026 12:17:24 +0100 Subject: [PATCH 05/15] End chapter reordering when switching storyline tabs The chapters of a newly shown storyline tab were not collapsed while the outline menu still offered finishing the reordering. Let TabsView trigger a change event and end reordering when the tab changes. --- .../editor/views/EntryOutlineView-spec.js | 24 ++++++++- .../src/editor/views/EntryOutlineView.js | 20 ++++---- .../src/editor/views/StorylinesTabsView.js | 1 + package/spec/ui/views/TabsView-spec.js | 49 +++++++++++++++++++ package/src/ui/views/TabsView.js | 11 ++++- 5 files changed, 93 insertions(+), 12 deletions(-) diff --git a/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js b/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js index b85b6c3d28..8bffddbb08 100644 --- a/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js @@ -11,7 +11,9 @@ describe('EntryOutlineView', () => { 'pageflow_scrolled.editor.storylines_tabs.main': 'Main', 'pageflow_scrolled.editor.storylines_tabs.excursions': 'Excursions', 'pageflow_scrolled.editor.storyline_item.excursion_blank_slate': 'Excursions provide additional content outside the main path. Create your first excursion.', - 'pageflow_scrolled.editor.entry_outline.trash': 'Deleted content' + 'pageflow_scrolled.editor.entry_outline.trash': 'Deleted content', + 'pageflow_scrolled.editor.entry_outline.reorder_chapters': 'Reorder chapters', + 'pageflow_scrolled.editor.entry_outline.finish_reorder_chapters': 'Finish reordering' }); const {createEntry} = useEditorGlobals(); @@ -389,4 +391,24 @@ describe('EntryOutlineView', () => { expect(screen.getByRole('button', {name: 'Deleted content'})).toBeInTheDocument(); }); + + it('ends chapter reordering when switching storyline tabs', async () => { + const entry = createEntry({ + storylines: [ + {id: 10, permaId: 200, position: 0, role: 'main'}, + {id: 11, permaId: 201, position: 1, configuration: {}} + ], + chapters: [ + {id: 1, permaId: 100, position: 0, storylineId: 10}, + {id: 2, permaId: 101, position: 0, storylineId: 11} + ] + }); + const user = userEvent.setup(); + + render(new EntryOutlineView({entry})); + await user.click(screen.getByRole('link', {name: 'Reorder chapters'})); + await user.click(screen.getByRole('tab', {name: 'Excursions'})); + + expect(screen.getByRole('link', {name: 'Reorder chapters'})).toBeInTheDocument(); + }); }); diff --git a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js index 79af1c5f0d..4cef3404f2 100644 --- a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js +++ b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js @@ -47,17 +47,17 @@ export const EntryOutlineView = Marionette.Layout.extend({ openOnClick: true }), {to: this.ui.dropDownButton}); - this.appendSubview( - new StorylinesTabsView({ + const storylinesTabsView = new StorylinesTabsView({ + entry: this.options.entry, + itemViewContstuctor: StorylineItemView, + itemViewOptions: { entry: this.options.entry, - itemViewContstuctor: StorylineItemView, - itemViewOptions: { - entry: this.options.entry, - viewModel - } - }), - {to: this.ui.tabs} - ); + viewModel + } + }); + + this.listenTo(storylinesTabsView, 'change', () => viewModel.set('collapsed', false)); + this.appendSubview(storylinesTabsView, {to: this.ui.tabs}); this.ui.tabs.find('.tabs_view-scroller').first().append(this.ui.toolbar); } diff --git a/entry_types/scrolled/package/src/editor/views/StorylinesTabsView.js b/entry_types/scrolled/package/src/editor/views/StorylinesTabsView.js index 0ddfda42ad..1be658b409 100644 --- a/entry_types/scrolled/package/src/editor/views/StorylinesTabsView.js +++ b/entry_types/scrolled/package/src/editor/views/StorylinesTabsView.js @@ -26,6 +26,7 @@ export const StorylinesTabsView = Marionette.View.extend({ } }) + this.listenTo(this.tabsView, 'change', name => this.trigger('change', name)); this.appendSubview(this.tabsView); return this; }, diff --git a/package/spec/ui/views/TabsView-spec.js b/package/spec/ui/views/TabsView-spec.js index a4c89d35ee..19c23c78e0 100644 --- a/package/spec/ui/views/TabsView-spec.js +++ b/package/spec/ui/views/TabsView-spec.js @@ -117,6 +117,55 @@ describe('TabsView', () => { }); }); + describe('change event', () => { + useFakeTranslations({ + 'tabs.one': 'Tab one', + 'tabs.two': 'Tab two' + }); + + function renderTabs() { + var tabsView = new TabsView({i18n: 'tabs'}); + + tabsView.tab('one', function() { return new Backbone.View(); }); + tabsView.tab('two', function() { return new Backbone.View(); }); + + var listener = jest.fn(); + tabsView.on('change', listener); + + return {tabsView, listener, ...renderBackboneView(tabsView)}; + } + + it('is triggered with name of tab when other tab is selected', () => { + var {listener, getByRole} = renderTabs(); + + fireEvent.click(getByRole('tab', {name: 'Tab two'})); + + expect(listener).toHaveBeenCalledWith('two'); + }); + + it('is not triggered for default tab', () => { + var {listener} = renderTabs(); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('is not triggered when current tab is selected again', () => { + var {listener, getByRole} = renderTabs(); + + fireEvent.click(getByRole('tab', {name: 'Tab one'})); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('is not triggered when tab is refreshed', () => { + var {tabsView, listener} = renderTabs(); + + tabsView.refresh(); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + describe('accessibility', () => { useFakeTranslations({ 'tabs.one': 'Tab one', diff --git a/package/src/ui/views/TabsView.js b/package/src/ui/views/TabsView.js index 62ea093c9f..bf77e5c0a0 100644 --- a/package/src/ui/views/TabsView.js +++ b/package/src/ui/views/TabsView.js @@ -27,6 +27,9 @@ import template from '../templates/tabsView.jst'; * @param {string} [options.i18n] * Legacy alias for `fallbackTranslationKeyPrefix`. * + * Triggers a `change` event with the name of the new tab when the + * user or a call to `changeTab` switches to a different tab. + * * @class */ export const TabsView = Marionette.Layout.extend(/* @lends TabView.prototype */{ @@ -106,7 +109,9 @@ export const TabsView = Marionette.Layout.extend(/* @lends TabView.prototype */{ return; } - if (this.currentTabName !== name || options.refresh) { + var previousTabName = this.currentTabName; + + if (previousTabName !== name || options.refresh) { this.container.show(this.tabFactoryFns[name]()); } @@ -114,6 +119,10 @@ export const TabsView = Marionette.Layout.extend(/* @lends TabView.prototype */{ this._updateActiveTab(name, options.focusTab); this._updateActivePanel(name); + + if (previousTabName && previousTabName !== name) { + this.trigger('change', name); + } }, defaultTab: function() { From be7f28b8ed43a631376e4b6038148979252be3b7 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 29 Oct 2026 10:53:01 +0100 Subject: [PATCH 06/15] Mark sections in outline The outline now holds a section selection. Clicking a section thumbnail with the Ctrl or Cmd key held marks the section and thereby activates the selection. While it is active, section thumbnails show the check circles of the files explorer. The circle's button covers the whole thumbnail, so clicking anywhere on it marks or unmarks the section and scrolls the preview to it. Marked thumbnails shrink a little to reveal the selection color behind them. Drag handles and section menus are hidden and sections cannot be reordered meanwhile. --- entry_types/scrolled/config/locales/de.yml | 1 + entry_types/scrolled/config/locales/en.yml | 1 + .../spec/editor/views/ChapterItemView-spec.js | 35 ++++++++ .../editor/views/EntryOutlineView-spec.js | 36 +++++++- .../spec/editor/views/SectionItemView-spec.js | 86 +++++++++++++++++++ .../src/editor/views/ChapterItemView.js | 21 ++++- .../src/editor/views/EntryOutlineView.js | 12 ++- .../src/editor/views/SectionItemView.js | 41 ++++++++- .../editor/views/SectionItemView.module.css | 40 +++++++++ .../src/editor/views/StorylineItemView.js | 3 +- 10 files changed, 266 insertions(+), 10 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index dcad2c05c7..43ebb1c2bc 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1499,6 +1499,7 @@ de: cutoff: Paywall Grenze drag_hint: Ziehen, um den Abschnitt zu verschieben hidden: Nur im Editor sichtbar + mark: Abschnitt markieren save_error: Beim Speichern des Abschnitts ist ein Fehler aufgetreten. transitions: beforeAfter: Statische Hintergründe diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index d82f5113e1..409beaa906 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1481,6 +1481,7 @@ en: cutoff: Paywall cutoff drag_hint: Drag to move section hidden: Only visible in editor + mark: Mark section save_error: There was an error while saving this section. transitions: beforeAfter: Static Backgrounds diff --git a/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js b/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js index 4ec1931276..2d9e13238f 100644 --- a/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js @@ -1,4 +1,6 @@ import {ChapterItemView} from 'editor/views/ChapterItemView'; +import {ListSelection} from 'pageflow/editor'; +import {SortableCollectionView} from 'pageflow/ui'; import {FragmentLibraryDialogView} from 'editor/views/FragmentLibraryDialogView'; import {useEditorGlobals, useFakeXhr} from 'support'; @@ -129,6 +131,39 @@ describe('ChapterItemView', () => { expect(screen.queryByRole('button', {name: 'Insert fragment'})).toBeNull(); }); + describe('with section selection', () => { + function renderChapterItem() { + const entry = createEntry({chapters: [{id: 1}], sections: [{id: 10, chapterId: 1}]}); + const sectionSelection = new ListSelection(); + const view = new ChapterItemView({entry, model: entry.chapters.get(1), sectionSelection}); + + render(view); + + return {entry, sectionSelection}; + } + + afterEach(() => jest.restoreAllMocks()); + + it('disables sorting of sections while selection is active', () => { + const disableSorting = jest.spyOn(SortableCollectionView.prototype, 'disableSorting'); + const {sectionSelection} = renderChapterItem(); + + sectionSelection.start(); + + expect(disableSorting).toHaveBeenCalled(); + }); + + it('enables sorting of sections again when selection ends', () => { + const {sectionSelection} = renderChapterItem(); + sectionSelection.start(); + const enableSorting = jest.spyOn(SortableCollectionView.prototype, 'enableSorting'); + + sectionSelection.stop(); + + expect(enableSorting).toHaveBeenCalled(); + }); + }); + describe('with fragments feature', () => { useFakeFeatures('editor', ['fragments']); diff --git a/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js b/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js index 8bffddbb08..218eef72d7 100644 --- a/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js @@ -3,7 +3,9 @@ import {EntryOutlineView} from 'editor/views/EntryOutlineView'; import {useEditorGlobals} from 'support'; import userEvent from '@testing-library/user-event'; import {screen, within} from '@testing-library/dom'; -import {useFakeTranslations, renderBackboneView as render} from 'pageflow/testHelpers'; +import { + useFakeFeatures, useFakeTranslations, renderBackboneView as render +} from 'pageflow/testHelpers'; import '@testing-library/jest-dom/extend-expect'; describe('EntryOutlineView', () => { @@ -13,7 +15,8 @@ describe('EntryOutlineView', () => { 'pageflow_scrolled.editor.storyline_item.excursion_blank_slate': 'Excursions provide additional content outside the main path. Create your first excursion.', 'pageflow_scrolled.editor.entry_outline.trash': 'Deleted content', 'pageflow_scrolled.editor.entry_outline.reorder_chapters': 'Reorder chapters', - 'pageflow_scrolled.editor.entry_outline.finish_reorder_chapters': 'Finish reordering' + 'pageflow_scrolled.editor.entry_outline.finish_reorder_chapters': 'Finish reordering', + 'pageflow_scrolled.editor.section_item.mark': 'Mark section' }); const {createEntry} = useEditorGlobals(); @@ -411,4 +414,33 @@ describe('EntryOutlineView', () => { expect(screen.getByRole('link', {name: 'Reorder chapters'})).toBeInTheDocument(); }); + + it('does not let sections be marked by default', () => { + const entry = createEntry({ + chapters: [{id: 1, permaId: 100, position: 0}], + sections: [{id: 10, permaId: 1000, chapterId: 1}] + }); + + render(new EntryOutlineView({entry})); + + expect(screen.queryByRole('button', {name: 'Mark section'})).toBeNull(); + }); + + describe('with fragments feature', () => { + useFakeFeatures('editor', ['fragments']); + + it('lets sections be marked', async () => { + const entry = createEntry({ + chapters: [{id: 1, permaId: 100, position: 0}], + sections: [{id: 10, permaId: 1000, chapterId: 1}] + }); + const user = userEvent.setup(); + + render(new EntryOutlineView({entry})); + await user.click(screen.getByRole('button', {name: 'Mark section'})); + + expect(screen.getByRole('button', {name: 'Mark section'})) + .toHaveAttribute('aria-pressed', 'true'); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js b/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js index 7d4c0ae825..1752f5f607 100644 --- a/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js @@ -1,7 +1,9 @@ import {SectionItemView} from 'editor/views/SectionItemView'; +import {ListSelection} from 'pageflow/editor'; import {useEditorGlobals, useFakeXhr} from 'support'; import {useFakeTranslations, renderBackboneView as render} from 'pageflow/testHelpers'; +import {fireEvent} from '@testing-library/dom'; import '@testing-library/jest-dom/extend-expect'; describe('SectionItemView', () => { @@ -13,6 +15,8 @@ describe('SectionItemView', () => { 'pageflow_scrolled.editor.section_menu_items.set_cutoff': 'Set cutoff point', 'pageflow_scrolled.editor.section_menu_items.reset_cutoff': 'Remove cutoff point', 'pageflow_scrolled.editor.section_item.cutoff': 'Cutoff point', + 'pageflow_scrolled.editor.section_item.drag_hint': 'Drag to move', + 'pageflow_scrolled.editor.section_item.mark': 'Mark section', }); const {createEntry} = useEditorGlobals(); @@ -227,4 +231,86 @@ describe('SectionItemView', () => { expect(view.el).not.toHaveAttribute('aria-current', 'true'); }); }); + + describe('marking', () => { + function renderSectionItem({sectionSelection} = {}) { + const entry = createEntry({sections: [{id: 1, permaId: 100}]}); + const section = entry.sections.get(1); + const view = new SectionItemView({entry, model: section, sectionSelection}); + + return {entry, section, ...render(view)}; + } + + it('does not render mark button without section selection', () => { + const {queryByRole} = renderSectionItem(); + + expect(queryByRole('button', {name: 'Mark section'})).toBeNull(); + }); + + it('renders unpressed mark button', () => { + const {getByRole} = renderSectionItem({sectionSelection: new ListSelection()}); + + expect(getByRole('button', {name: 'Mark section'})) + .toHaveAttribute('aria-pressed', 'false'); + }); + + it('marks section when mark button is clicked', () => { + const sectionSelection = new ListSelection(); + const {section, getByRole} = renderSectionItem({sectionSelection}); + + fireEvent.click(getByRole('button', {name: 'Mark section'})); + + expect(sectionSelection.includes(section)).toBe(true); + expect(getByRole('button', {name: 'Mark section'})) + .toHaveAttribute('aria-pressed', 'true'); + }); + + it('unmarks marked section when mark button is clicked', () => { + const sectionSelection = new ListSelection(); + const {section, getByRole} = renderSectionItem({sectionSelection}); + sectionSelection.add(section); + + fireEvent.click(getByRole('button', {name: 'Mark section'})); + + expect(sectionSelection.includes(section)).toBe(false); + }); + + it('marks section on modifier click', () => { + const sectionSelection = new ListSelection(); + const {section, getByTitle} = renderSectionItem({sectionSelection}); + + fireEvent.click(getByTitle('Drag to move'), {ctrlKey: true}); + + expect(sectionSelection.includes(section)).toBe(true); + }); + + it('marks section on click with meta key', () => { + const sectionSelection = new ListSelection(); + const {section, getByTitle} = renderSectionItem({sectionSelection}); + + fireEvent.click(getByTitle('Drag to move'), {metaKey: true}); + + expect(sectionSelection.includes(section)).toBe(true); + }); + + it('does not mark section on plain click', () => { + const sectionSelection = new ListSelection(); + const {section, getByTitle} = renderSectionItem({sectionSelection}); + + fireEvent.click(getByTitle('Drag to move')); + + expect(sectionSelection.includes(section)).toBe(false); + }); + + it('scrolls to section when mark button is clicked', () => { + const sectionSelection = new ListSelection(); + const {entry, section, getByRole} = renderSectionItem({sectionSelection}); + const listener = jest.fn(); + entry.on('scrollToSection', listener); + + fireEvent.click(getByRole('button', {name: 'Mark section'})); + + expect(listener).toHaveBeenCalledWith(section); + }); + }); }); diff --git a/entry_types/scrolled/package/src/editor/views/ChapterItemView.js b/entry_types/scrolled/package/src/editor/views/ChapterItemView.js index 14e0123368..8cea267875 100644 --- a/entry_types/scrolled/package/src/editor/views/ChapterItemView.js +++ b/entry_types/scrolled/package/src/editor/views/ChapterItemView.js @@ -70,20 +70,37 @@ export const ChapterItemView = Marionette.Layout.extend({ }, onRender() { - this.subview(new SortableCollectionView({ + this.sectionsView = this.subview(new SortableCollectionView({ el: this.ui.sections, collection: this.model.sections, itemViewConstructor: SectionItemView, itemViewOptions: { - entry: this.options.entry + entry: this.options.entry, + sectionSelection: this.options.sectionSelection }, connectWith: cssModulesUtils.selector(styles, 'sections'), forceDraggableFallback: browser.agent.matchesDesktopSafari() })); + if (this.options.sectionSelection) { + this.listenTo(this.options.sectionSelection, + 'add remove reset change:selecting', + this.updateSorting); + this.updateSorting(); + } + this.update(); }, + updateSorting() { + if (this.options.sectionSelection.isActive()) { + this.sectionsView.disableSorting(); + } + else { + this.sectionsView.enableSorting(); + } + }, + update() { this.ui.title.toggleClass(styles.blank, !this.model.configuration.get('title') && diff --git a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js index 4cef3404f2..26172db4c2 100644 --- a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js +++ b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js @@ -2,7 +2,8 @@ import Marionette from 'backbone.marionette'; import Backbone from 'backbone'; import I18n from 'i18n-js'; import {cssModulesUtils} from 'pageflow/ui'; -import {DropDownButtonView} from 'pageflow/editor'; +import {DropDownButtonView, ListSelection} from 'pageflow/editor'; +import {features} from 'pageflow/frontend'; import {StorylinesTabsView} from './StorylinesTabsView'; import {StorylineItemView} from './StorylineItemView'; @@ -23,6 +24,12 @@ export const EntryOutlineView = Marionette.Layout.extend({ ui: cssModulesUtils.ui(styles, 'tabs', 'toolbar', 'dropDownButton'), + initialize() { + if (features.isEnabled('fragments')) { + this.sectionSelection = new ListSelection(); + } + }, + onRender() { this.ui.toolbar.prepend(this.subview(new TrashButtonView({ entry: this.options.entry, @@ -52,7 +59,8 @@ export const EntryOutlineView = Marionette.Layout.extend({ itemViewContstuctor: StorylineItemView, itemViewOptions: { entry: this.options.entry, - viewModel + viewModel, + sectionSelection: this.sectionSelection } }); diff --git a/entry_types/scrolled/package/src/editor/views/SectionItemView.js b/entry_types/scrolled/package/src/editor/views/SectionItemView.js index 3e69e6d794..a461d4f38f 100644 --- a/entry_types/scrolled/package/src/editor/views/SectionItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SectionItemView.js @@ -1,7 +1,7 @@ import I18n from 'i18n-js'; import Backbone from 'backbone'; import Marionette from 'backbone.marionette'; -import {modelLifecycleTrackingView, DropDownButtonView} from 'pageflow/editor'; +import {modelLifecycleTrackingView, DropDownButtonView, MarkButtonView} from 'pageflow/editor'; import {cssModulesUtils} from 'pageflow/ui'; import {SectionThumbnailView} from './SectionThumbnailView' @@ -36,6 +36,7 @@ export const SectionItemView = Marionette.ItemView.extend({ title="${I18n.t('pageflow_scrolled.editor.section_item.drag_hint')}"> +
@@ -50,10 +51,14 @@ export const SectionItemView = Marionette.ItemView.extend({ ui: cssModulesUtils.ui(styles, 'thumbnail', 'dropDownButton', 'editTransition', - 'transition', 'cutoffIndicator'), + 'transition', 'cutoffIndicator', 'markButton'), events: cssModulesUtils.events(styles, { - 'click clickMask': function() { + 'click clickMask': function(event) { + if (this.options.sectionSelection && (event.ctrlKey || event.metaKey)) { + this.options.sectionSelection.toggle(this.model); + } + this.options.entry.trigger('selectSection', this.model); this.options.entry.trigger('scrollToSection', this.model); }, @@ -93,12 +98,19 @@ export const SectionItemView = Marionette.ItemView.extend({ this.listenTo(this.model.configuration, 'change:hidden', () => { this.updateHidden(); }); + + if (this.options.sectionSelection) { + this.listenTo(this.options.sectionSelection, + 'add remove reset change:selecting', + this.updateMarked); + } }, onRender() { this.updateTransition(); this.updateCutoffIndicator(); this.updateHidden(); + this.updateMarked(); if (this.updateActive()) { setTimeout(() => this.$el[0].scrollIntoView({block: 'nearest'}), 10) @@ -123,6 +135,24 @@ export const SectionItemView = Marionette.ItemView.extend({ borderless: true, openOnClick: true }), {to: this.ui.dropDownButton}); + + if (this.options.sectionSelection) { + this.appendMarkButton(); + } + }, + + appendMarkButton() { + const markButton = new MarkButtonView({ + model: this.model, + selection: this.options.sectionSelection, + label: I18n.t('pageflow_scrolled.editor.section_item.mark') + }); + + this.appendSubview(markButton, {to: this.ui.markButton}); + this.listenTo(markButton, 'toggle', () => { + this.options.entry.trigger('selectSection', this.model); + this.options.entry.trigger('scrollToSection', this.model); + }); }, updateTransition() { @@ -150,6 +180,11 @@ export const SectionItemView = Marionette.ItemView.extend({ return active; }, + updateMarked() { + this.$el.toggleClass(styles.marking, !!this.options.sectionSelection?.isActive()); + this.$el.toggleClass(styles.marked, !!this.options.sectionSelection?.includes(this.model)); + }, + updateHidden() { this.$el.toggleClass(styles.hidden, !!this.model.configuration.get('hidden')); } 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 338fb3115a..72d2df5dc6 100644 --- a/entry_types/scrolled/package/src/editor/views/SectionItemView.module.css +++ b/entry_types/scrolled/package/src/editor/views/SectionItemView.module.css @@ -54,6 +54,11 @@ .thumbnailContainer { position: relative; background-color: var(--ui-surface-color); + transition: transform 0.15s ease; +} + +.marked .thumbnailContainer { + transform: scale(0.92); } .hidden .thumbnail > * > * { @@ -117,6 +122,41 @@ display: none; } +.markButton { + display: none; + position: absolute; + inset: 0; + z-index: 1; +} + +.marking .markButton { + display: block; +} + +.markButton :global(.mark_button) { + position: absolute; + inset: 0; + width: auto; + height: auto; +} + +.markButton :global(.mark_button)::before { + position: absolute; + top: 4px; + left: 4px; + width: space(6); + text-align: center; +} + +.marking .dragHandle, +.marking .dropDownButton { + display: none; +} + +.marked .outline { + background-color: var(--ui-selection-color-lighter); +} + .editTransition { composes: transition from './outline.module.css'; border: 0; diff --git a/entry_types/scrolled/package/src/editor/views/StorylineItemView.js b/entry_types/scrolled/package/src/editor/views/StorylineItemView.js index 3a97fd3eb9..a7c39ac457 100644 --- a/entry_types/scrolled/package/src/editor/views/StorylineItemView.js +++ b/entry_types/scrolled/package/src/editor/views/StorylineItemView.js @@ -61,7 +61,8 @@ export const StorylineItemView = Marionette.Layout.extend({ collection: this.model.chapters, itemViewConstructor: ChapterItemView, itemViewOptions: { - entry: this.options.entry + entry: this.options.entry, + sectionSelection: this.options.sectionSelection }, blankSlateViewConstructor: this.model.isMain() ? null : ExcursionBlankSlateView }); From 5e826cbd30457971e7f0b82f77bcc4fc2f30babe Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 29 Oct 2026 10:56:10 +0100 Subject: [PATCH 07/15] Add marked sections to shared fragments from outline The outline menu offers selecting sections and, while the selection is active, ending it. During the selection, a bar between storyline tabs and chapters sticks to the top of the sidebar. It shows the number of marked sections and offers adding them to the shared fragments, styled like the selection bar of the files view. Adding the sections or ending the selection unmarks all sections and hides the check circles again. Selecting sections, reordering chapters and switching storyline tabs end each other. --- .../pageflow/editor/composables.scss | 10 + entry_types/scrolled/config/locales/de.yml | 5 + entry_types/scrolled/config/locales/en.yml | 5 + .../editor/views/EntryOutlineView-spec.js | 190 +++++++++++++++++- .../src/editor/views/EntryOutlineView.js | 129 +++++++++++- .../editor/views/EntryOutlineView.module.css | 66 ++++++ .../src/editor/views/banner.module.css | 8 + .../src/editor/views/buttons.module.css | 8 + 8 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 entry_types/scrolled/package/src/editor/views/banner.module.css diff --git a/app/assets/stylesheets/pageflow/editor/composables.scss b/app/assets/stylesheets/pageflow/editor/composables.scss index fe623b7fcb..248166f4dc 100644 --- a/app/assets/stylesheets/pageflow/editor/composables.scss +++ b/app/assets/stylesheets/pageflow/editor/composables.scss @@ -12,6 +12,16 @@ @include icon-button; } +.secondary_button { + @include simple-button; +} + .destructive_icon_button { @include icon-button("destructive"); } + +// Defined last to override the padding of the buttons above. +.compact_button { + padding-top: space(1); + padding-bottom: space(1); +} diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 43ebb1c2bc..da1da87a16 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1445,9 +1445,14 @@ de: remove: Entfernen entry_outline: add_chapter: Neues Kapitel + end_selection: Auswahl beenden header: Gliederung finish_reorder_chapters: Umsortieren beenden reorder_chapters: "Kapitel umsortieren..." + select_sections: "Abschnitte auswählen..." + selected_sections: + one: 1 Abschnitt ausgewählt + other: "%{count} Abschnitte ausgewählt" trash: Gelöschte Inhalte entry_select: no_matches: Keine passenden Beiträge diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 409beaa906..046ac34149 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1427,9 +1427,14 @@ en: remove: Remove entry_outline: add_chapter: New chapter + end_selection: End selection header: Outline finish_reorder_chapters: Finish reordering reorder_chapters: "Reorder chapters..." + select_sections: "Select sections..." + selected_sections: + one: 1 section selected + other: "%{count} sections selected" trash: Deleted content entry_select: no_matches: No matching stories diff --git a/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js b/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js index 218eef72d7..ba77adea94 100644 --- a/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js @@ -1,4 +1,5 @@ import {EntryOutlineView} from 'editor/views/EntryOutlineView'; +import {ExtractFragmentDialogView} from 'editor/views/ExtractFragmentDialogView'; import {useEditorGlobals} from 'support'; import userEvent from '@testing-library/user-event'; @@ -16,7 +17,14 @@ describe('EntryOutlineView', () => { 'pageflow_scrolled.editor.entry_outline.trash': 'Deleted content', 'pageflow_scrolled.editor.entry_outline.reorder_chapters': 'Reorder chapters', 'pageflow_scrolled.editor.entry_outline.finish_reorder_chapters': 'Finish reordering', - 'pageflow_scrolled.editor.section_item.mark': 'Mark section' + 'pageflow_scrolled.editor.section_item.mark': 'Mark section', + 'pageflow_scrolled.editor.entry_outline.select_sections': 'Select sections', + 'pageflow_scrolled.editor.entry_outline.end_selection': 'End selection', + 'pageflow_scrolled.editor.entry_outline.selected_sections.one': '1 section selected', + 'pageflow_scrolled.editor.entry_outline.selected_sections.other': + '%{count} sections selected', + 'pageflow_scrolled.editor.extract_sections_fragment_menu_item.label': + 'Add to shared fragments' }); const {createEntry} = useEditorGlobals(); @@ -426,6 +434,12 @@ describe('EntryOutlineView', () => { expect(screen.queryByRole('button', {name: 'Mark section'})).toBeNull(); }); + it('does not offer selecting sections by default', () => { + render(new EntryOutlineView({entry: createEntry({})})); + + expect(screen.queryByRole('link', {name: 'Select sections'})).toBeNull(); + }); + describe('with fragments feature', () => { useFakeFeatures('editor', ['fragments']); @@ -442,5 +456,179 @@ describe('EntryOutlineView', () => { expect(screen.getByRole('button', {name: 'Mark section'})) .toHaveAttribute('aria-pressed', 'true'); }); + + function createEntryWithSections() { + return createEntry({ + chapters: [{id: 1, permaId: 100, position: 0}], + sections: [ + {id: 10, permaId: 1000, chapterId: 1, position: 0}, + {id: 11, permaId: 1100, chapterId: 1, position: 1} + ] + }); + } + + it('does not show selection bar while no section is marked', () => { + render(new EntryOutlineView({entry: createEntryWithSections()})); + + expect(screen.queryByRole('button', {name: 'End selection'})).toBeNull(); + }); + + it('shows number of marked sections in selection bar', async () => { + const user = userEvent.setup(); + + render(new EntryOutlineView({entry: createEntryWithSections()})); + const [first, second] = screen.getAllByRole('button', {name: 'Mark section'}); + await user.click(first); + await user.click(second); + + expect(screen.getByText('2 sections selected')).toBeInTheDocument(); + }); + + it('shows selection bar when selecting sections from menu', async () => { + const user = userEvent.setup(); + + render(new EntryOutlineView({entry: createEntryWithSections()})); + await user.click(screen.getByRole('link', {name: 'Select sections'})); + + expect(screen.getByText('0 sections selected')).toBeInTheDocument(); + }); + + it('offers ending selection in menu while selecting sections', async () => { + const user = userEvent.setup(); + + render(new EntryOutlineView({entry: createEntryWithSections()})); + await user.click(screen.getByRole('link', {name: 'Select sections'})); + await user.click(screen.getByRole('link', {name: 'End selection'})); + + expect(screen.queryByRole('button', {name: 'End selection'})).toBeNull(); + expect(screen.getByRole('link', {name: 'Select sections'})).toBeInTheDocument(); + }); + + it('renders selection bar between tab bar and chapters', async () => { + const user = userEvent.setup(); + + render(new EntryOutlineView({entry: createEntryWithSections()})); + await user.click(screen.getByRole('link', {name: 'Select sections'})); + const bar = screen.getByText('0 sections selected'); + + expect(screen.getByRole('tablist').compareDocumentPosition(bar) & + Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(screen.getByRole('tabpanel').compareDocumentPosition(bar) & + Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy(); + }); + + it('ends section selection when switching storyline tabs', async () => { + const entry = createEntry({ + storylines: [ + {id: 10, permaId: 200, position: 0, role: 'main'}, + {id: 11, permaId: 201, position: 1, configuration: {}} + ], + chapters: [{id: 1, permaId: 100, position: 0, storylineId: 10}], + sections: [{id: 10, permaId: 1000, chapterId: 1}] + }); + const user = userEvent.setup(); + + render(new EntryOutlineView({entry})); + await user.click(screen.getByRole('button', {name: 'Mark section'})); + await user.click(screen.getByRole('tab', {name: 'Excursions'})); + + expect(screen.queryByRole('button', {name: 'End selection'})).toBeNull(); + }); + + it('ends chapter reordering when selecting sections', async () => { + const user = userEvent.setup(); + + render(new EntryOutlineView({entry: createEntryWithSections()})); + await user.click(screen.getByRole('link', {name: 'Reorder chapters'})); + await user.click(screen.getByRole('link', {name: 'Select sections'})); + + expect(screen.getByRole('link', {name: 'Reorder chapters'})).toBeInTheDocument(); + }); + + it('ends section selection when reordering chapters', async () => { + const user = userEvent.setup(); + + render(new EntryOutlineView({entry: createEntryWithSections()})); + await user.click(screen.getByRole('link', {name: 'Select sections'})); + await user.click(screen.getByRole('link', {name: 'Reorder chapters'})); + + expect(screen.queryByRole('button', {name: 'End selection'})).toBeNull(); + }); + + it('unmarks sections when selection is ended', async () => { + const user = userEvent.setup(); + + render(new EntryOutlineView({entry: createEntryWithSections()})); + await user.click(screen.getAllByRole('button', {name: 'Mark section'})[0]); + await user.click(screen.getByRole('button', {name: 'End selection'})); + + expect(screen.queryByRole('button', {name: 'End selection'})).toBeNull(); + expect(screen.getAllByRole('button', {name: 'Mark section'})[0]) + .toHaveAttribute('aria-pressed', 'false'); + }); + + it('drops removed sections from selection', async () => { + const entry = createEntryWithSections(); + const user = userEvent.setup(); + + render(new EntryOutlineView({entry})); + const [first, second] = screen.getAllByRole('button', {name: 'Mark section'}); + await user.click(first); + await user.click(second); + entry.sections.remove(entry.sections.get(11)); + + expect(screen.getByText('1 section selected')).toBeInTheDocument(); + }); + + it('adds marked sections to shared fragments', async () => { + const entry = createEntryWithSections(); + entry.extractSectionsToFragmentLibrary = jest.fn(() => new Promise(() => {})); + const show = jest.spyOn(ExtractFragmentDialogView, 'show').mockImplementation(() => {}); + const user = userEvent.setup(); + + render(new EntryOutlineView({entry})); + const [first, second] = screen.getAllByRole('button', {name: 'Mark section'}); + await user.click(second); + await user.click(first); + await user.click(screen.getByRole('button', {name: 'Add to shared fragments'})); + show.mock.calls[0][0].onSubmit('Intro'); + + expect(entry.extractSectionsToFragmentLibrary).toHaveBeenCalledWith( + [entry.sections.get(11), entry.sections.get(10)], + {title: 'Intro'} + ); + show.mockRestore(); + }); + + it('keeps selection while name dialog has not been submitted', async () => { + const entry = createEntryWithSections(); + const show = jest.spyOn(ExtractFragmentDialogView, 'show').mockImplementation(() => {}); + const user = userEvent.setup(); + + render(new EntryOutlineView({entry})); + await user.click(screen.getAllByRole('button', {name: 'Mark section'})[0]); + await user.click(screen.getByRole('button', {name: 'Add to shared fragments'})); + + expect(screen.getAllByRole('button', {name: 'Mark section'})[0]) + .toHaveAttribute('aria-pressed', 'true'); + show.mockRestore(); + }); + + it('ends selection when name dialog is submitted', async () => { + const entry = createEntryWithSections(); + entry.extractSectionsToFragmentLibrary = jest.fn(() => new Promise(() => {})); + const show = jest.spyOn(ExtractFragmentDialogView, 'show').mockImplementation(() => {}); + const user = userEvent.setup(); + + render(new EntryOutlineView({entry})); + await user.click(screen.getAllByRole('button', {name: 'Mark section'})[0]); + await user.click(screen.getByRole('button', {name: 'Add to shared fragments'})); + show.mock.calls[0][0].onSubmit('Intro'); + + expect(screen.queryByRole('button', {name: 'End selection'})).toBeNull(); + expect(screen.getAllByRole('button', {name: 'Mark section'})[0]) + .toHaveAttribute('aria-pressed', 'false'); + show.mockRestore(); + }); }); }); diff --git a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js index 26172db4c2..c07947ab51 100644 --- a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js +++ b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js @@ -8,6 +8,8 @@ import {features} from 'pageflow/frontend'; import {StorylinesTabsView} from './StorylinesTabsView'; import {StorylineItemView} from './StorylineItemView'; import {TrashButtonView} from './TrashButtonView'; +import {ExtractFragmentDialogView} from './ExtractFragmentDialogView'; +import {ExtractSectionsFragmentMenuItem} from '../models/sectionMenuItems'; import styles from './EntryOutlineView.module.css'; @@ -20,9 +22,48 @@ export const EntryOutlineView = Marionette.Layout.extend({
+ `, - ui: cssModulesUtils.ui(styles, 'tabs', 'toolbar', 'dropDownButton'), + ui: cssModulesUtils.ui(styles, + 'tabs', 'toolbar', 'dropDownButton', + 'selectionBar', 'selectionBarText', 'selectionBarAction'), + + events: cssModulesUtils.events(styles, { + 'click selectionBarDismiss': function() { + this.sectionSelection.stop(); + }, + + 'click selectionBarAction': function() { + const menuItem = new ExtractSectionsFragmentMenuItem({}, { + entry: this.options.entry, + sections: this.sectionSelection.models.slice() + }); + + ExtractFragmentDialogView.show({ + onSubmit: title => { + this.sectionSelection.stop(); + menuItem.extract(title); + } + }); + } + }), initialize() { if (features.isEnabled('fragments')) { @@ -46,6 +87,18 @@ export const EntryOutlineView = Marionette.Layout.extend({ dropDownMenuItems.add(this.reorderChaptersMenutItem); + if (this.sectionSelection) { + dropDownMenuItems.add(new SelectSectionsMenuItem({}, { + sectionSelection: this.sectionSelection + })); + + this.setupSelectionBar(); + this.makeSelectionAndReorderingExclusive(viewModel); + } + else { + this.ui.selectionBar.remove(); + } + this.appendSubview(new DropDownButtonView({ items: dropDownMenuItems, alignMenu: 'right', @@ -64,10 +117,82 @@ export const EntryOutlineView = Marionette.Layout.extend({ } }); - this.listenTo(storylinesTabsView, 'change', () => viewModel.set('collapsed', false)); + this.listenTo(storylinesTabsView, 'change', () => { + viewModel.set('collapsed', false); + this.sectionSelection?.stop(); + }); + this.appendSubview(storylinesTabsView, {to: this.ui.tabs}); this.ui.tabs.find('.tabs_view-scroller').first().append(this.ui.toolbar); + + if (this.sectionSelection) { + this.ui.tabs.find('.tabs_view-container').first().before(this.ui.selectionBar); + } + }, + + setupSelectionBar() { + this.listenTo(this.sectionSelection, + 'add remove reset change:selecting', + this.updateSelectionBar); + + this.listenTo(this.options.entry.sections, 'remove', section => + this.sectionSelection.remove(section) + ); + + this.updateSelectionBar(); + }, + + makeSelectionAndReorderingExclusive(viewModel) { + this.listenTo(viewModel, 'change:collapsed', (model, collapsed) => { + if (collapsed) { + this.sectionSelection.stop(); + } + }); + + this.listenTo(this.sectionSelection, 'change:selecting add', () => { + if (this.sectionSelection.isActive()) { + viewModel.set('collapsed', false); + } + }); + }, + + updateSelectionBar() { + const selection = this.sectionSelection; + + this.ui.selectionBar.prop('hidden', !selection.isActive()); + this.ui.selectionBarText.text( + I18n.t('pageflow_scrolled.editor.entry_outline.selected_sections', + {count: selection.length}) + ); + + this.ui.selectionBarAction.prop('disabled', !selection.length); + } +}); + +const SelectSectionsMenuItem = Backbone.Model.extend({ + initialize(attributes, {sectionSelection}) { + this.sectionSelection = sectionSelection; + + this.listenTo(sectionSelection, 'add remove reset change:selecting', this.updateLabel); + this.updateLabel(); + }, + + selected() { + if (this.sectionSelection.isActive()) { + this.sectionSelection.stop(); + } + else { + this.sectionSelection.start(); + } + }, + + updateLabel() { + this.set('label', I18n.t( + this.sectionSelection.isActive() ? + 'pageflow_scrolled.editor.entry_outline.end_selection' : + 'pageflow_scrolled.editor.entry_outline.select_sections' + )); } }); diff --git a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.module.css b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.module.css index edc805967c..48ad0eedba 100644 --- a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.module.css +++ b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.module.css @@ -23,3 +23,69 @@ --ui-on-button-color: var(--ui-primary-color); } } + +.selectionBar { + position: sticky; + /* Padding of .scrolling in pageflow/editor/base.scss */ + top: -10px; + z-index: 2; + display: flow-root; + background-color: var(--ui-surface-color); +} + +.selectionBarBox { + composes: banner from './banner.module.css'; + margin: space(3) 0 space(2); +} + +.selectionBar[hidden] { + display: none; +} + +.selectionBar:not([hidden]) + :global(.tabs_view-container) { + /* Cancels margin-top of .chapters in StorylineItemView.module.css */ + margin-top: space(-3); +} + +.selectionBarStatus { + display: flex; + align-items: center; + gap: space(2); +} + +.selectionBarText { + flex: 1; +} + +.selectionBarActions { + display: flex; + align-self: flex-end; + gap: space(1); +} + +.selectionBarDismiss { + composes: cancel from './icons.module.css'; + flex: none; + width: space(6); + padding: space(1) 0; + border: none; + border-radius: rounded(); + background-color: transparent; + color: var(--ui-primary-color-light); + line-height: 16px; + cursor: pointer; +} + +.selectionBarDismiss::before { + font-size: 16px; + vertical-align: top; +} + +.selectionBarDismiss:hover { + background-color: var(--ui-on-surface-color-lightest); + color: var(--ui-primary-color); +} + +.selectionBarAction { + composes: secondaryButton compactButton from './buttons.module.css'; +} diff --git a/entry_types/scrolled/package/src/editor/views/banner.module.css b/entry_types/scrolled/package/src/editor/views/banner.module.css new file mode 100644 index 0000000000..4baefdedb6 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/banner.module.css @@ -0,0 +1,8 @@ +.banner { + display: flex; + flex-direction: column; + gap: space(2); + padding: space(2); + border-radius: rounded(); + background-color: var(--ui-selection-color-lighter); +} diff --git a/entry_types/scrolled/package/src/editor/views/buttons.module.css b/entry_types/scrolled/package/src/editor/views/buttons.module.css index f9563c0bfa..c1be5139a3 100644 --- a/entry_types/scrolled/package/src/editor/views/buttons.module.css +++ b/entry_types/scrolled/package/src/editor/views/buttons.module.css @@ -8,6 +8,14 @@ composes: secondary_icon_button from global; } +.secondaryButton { + composes: secondary_button from global; +} + +.compactButton { + composes: compact_button from global; +} + .destructiveIconButton { composes: destructive_icon_button from global; } From 71630057d736a55846e0e86c70cab3542bcda9af Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 29 Oct 2026 10:58:17 +0100 Subject: [PATCH 08/15] Support selection mode for content elements in preview The editor can now start a multi selection of content elements in selection mode. While it is active, plain clicks add content elements to or remove them from the selection, the copy cursor is shown over all content elements and the selection stays a multi selection even when only a single content element is left. Pressing Escape or a message from the editor ends the mode but keeps the selection. Clicking a section outside of content elements replaces the selection as before. --- .../contentElementMultiSelection-spec.js | 159 +++++++++++++++++- .../inlineEditing/multiSelection-spec.js | 80 ++++++++- .../inlineEditing/ContentElementDecorator.js | 7 +- .../ContentElementDecorator.module.css | 4 +- .../EditableText/useMultiSelectHandler.js | 7 +- .../src/frontend/inlineEditing/EditorState.js | 29 +++- .../frontend/inlineEditing/EntryDecorator.js | 37 +++- .../frontend/inlineEditing/multiSelection.js | 26 +++ 8 files changed, 335 insertions(+), 14 deletions(-) diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementMultiSelection-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementMultiSelection-spec.js index 4a0181e6f0..486806de91 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementMultiSelection-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementMultiSelection-spec.js @@ -5,7 +5,7 @@ import {frontend} from 'pageflow-scrolled/frontend'; import {editableTextValue} from 'support'; import {useInlineEditingPageObjects, renderEntry} from 'support/pageObjects/inlineEditing'; -import {fireEvent} from '@testing-library/react'; +import {act, fireEvent} from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect' describe('inline editing content element multi selection', () => { @@ -209,4 +209,161 @@ describe('inline editing content element multi selection', () => { expect(queryAllByRole('status')).toHaveLength(0); }); + + describe('selection mode', () => { + function startSelectionMode(items) { + act(() => { + window.dispatchEvent(new MessageEvent('message', { + data: { + type: 'SELECT', + payload: { + type: 'multiSelection', + itemType: 'contentElement', + items, + selectionMode: true + } + }, + origin: window.location.origin + })); + }); + } + + function renderEntryWithTwoContentElements() { + return renderEntry({ + seed: { + sections: [{id: 1, permaId: 10}], + contentElements: [ + {id: 1, sectionId: 1, typeName: 'withTestId', configuration: {testId: 5}}, + {id: 2, sectionId: 1, typeName: 'withTestId', configuration: {testId: 6}} + ] + } + }); + } + + it('adds content element to selection on plain click', () => { + const {getContentElementByTestId} = renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}]); + getContentElementByTestId(6).select(); + + expect(getContentElementByTestId(5).isSelected()).toBe(true); + expect(getContentElementByTestId(6).isSelected()).toBe(true); + }); + + it('removes content element from selection on plain click', () => { + const {getContentElementByTestId} = renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}, {id: 2}]); + getContentElementByTestId(6).select(); + + expect(getContentElementByTestId(5).isSelected()).toBe(true); + expect(getContentElementByTestId(6).isSelected()).toBe(false); + }); + + it('stays in selection mode when only one content element is left', () => { + const {getContentElementByTestId} = renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}, {id: 2}]); + getContentElementByTestId(6).select(); + getContentElementByTestId(6).select(); + + expect(getContentElementByTestId(5).isSelected()).toBe(true); + expect(getContentElementByTestId(6).isSelected()).toBe(true); + }); + + it('keeps last content element selected when it is clicked again', () => { + const {getContentElementByTestId} = renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}]); + getContentElementByTestId(5).select(); + + expect(getContentElementByTestId(5).isSelected()).toBe(true); + }); + + it('ends selection mode on escape', () => { + const {getContentElementByTestId} = renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}]); + fireEvent.keyDown(window, {key: 'Escape'}); + getContentElementByTestId(6).select(); + + expect(getContentElementByTestId(5).isSelected()).toBe(false); + expect(getContentElementByTestId(6).isSelected()).toBe(true); + }); + + it('keeps selection when selection mode ends', () => { + const {getContentElementByTestId} = renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}, {id: 2}]); + fireEvent.keyDown(window, {key: 'Escape'}); + + expect(getContentElementByTestId(5).isSelected()).toBe(true); + expect(getContentElementByTestId(6).isSelected()).toBe(true); + }); + + it('ends selection mode when editor requests it', () => { + const {getContentElementByTestId} = renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}]); + act(() => { + window.dispatchEvent(new MessageEvent('message', { + data: {type: 'END_SELECTION_MODE'}, + origin: window.location.origin + })); + }); + getContentElementByTestId(6).select(); + + expect(getContentElementByTestId(5).isSelected()).toBe(false); + expect(getContentElementByTestId(6).isSelected()).toBe(true); + }); + + it('ends selection mode when section is clicked', () => { + const {getContentElementByTestId, getSectionByPermaId} = + renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}]); + fireEvent.mouseDown(getSectionByPermaId(10).el); + getContentElementByTestId(6).select(); + + expect(getContentElementByTestId(5).isSelected()).toBe(false); + expect(getContentElementByTestId(6).isSelected()).toBe(true); + }); + + it('flags document while in selection mode', () => { + renderEntryWithTwoContentElements(); + + startSelectionMode([{id: 1}]); + + expect(document.documentElement).toHaveAttribute('data-selection-mode'); + + fireEvent.keyDown(window, {key: 'Escape'}); + + expect(document.documentElement).not.toHaveAttribute('data-selection-mode'); + }); + + it('does not focus editable text when clicked in selection mode', () => { + const {getContentElementByTestId} = renderEntry({ + seed: { + contentElements: [ + {id: 1, typeName: 'withTestId', configuration: {testId: 5}}, + { + id: 2, + typeName: 'withEditableInlineText', + configuration: { + testId: 6, + value: editableTextValue('Some heading', {type: 'heading'}) + } + } + ] + } + }); + const editableText = getContentElementByTestId(6).editableText(); + + startSelectionMode([{id: 1}]); + editableText.click('Some heading'); + + expect(editableText.isFocused()).toBe(false); + expect(getContentElementByTestId(6).isSelected()).toBe(true); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/multiSelection-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/multiSelection-spec.js index 79fff6b2e3..d02e0f64c7 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/multiSelection-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/multiSelection-spec.js @@ -1,7 +1,9 @@ import { extendMultiSelection, isInMultiSelection, - watchSelectionModifier + isInSelectionMode, + watchSelectionModifier, + withoutSelectionMode } from 'frontend/inlineEditing/multiSelection'; describe('extendMultiSelection', () => { @@ -97,6 +99,58 @@ describe('extendMultiSelection', () => { expect(extendMultiSelection(selection, {type: 'contentElement', id: 5})) .toEqual({type: 'contentElement', id: 5}); }); + + it('keeps multi selection with single item in selection mode', () => { + const selection = { + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 5}, {id: 6}], + selectionMode: true + }; + + expect(extendMultiSelection(selection, {type: 'contentElement', id: 6})) + .toEqual({ + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 5}], + selectionMode: true + }); + }); + + it('keeps last item in selection mode', () => { + const selection = { + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 5}], + selectionMode: true + }; + + expect(extendMultiSelection(selection, {type: 'contentElement', id: 5})) + .toBe(selection); + }); +}); + +describe('isInSelectionMode', () => { + it('returns true for multi selection in selection mode', () => { + expect(isInSelectionMode({ + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 5}], + selectionMode: true + })).toBe(true); + }); + + it('returns false for multi selection', () => { + expect(isInSelectionMode({ + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 5}, {id: 6}] + })).toBe(false); + }); + + it('returns false without selection', () => { + expect(isInSelectionMode(null)).toBe(false); + }); }); describe('isInMultiSelection', () => { @@ -182,3 +236,27 @@ describe('watchSelectionModifier', () => { expect(isFlagged()).toBe(false); }); }); + +describe('withoutSelectionMode', () => { + it('keeps multi selection of several items', () => { + expect(withoutSelectionMode({ + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 5}, {id: 6}], + selectionMode: true + })).toEqual({ + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 5}, {id: 6}] + }); + }); + + it('turns multi selection of single item into selection of item', () => { + expect(withoutSelectionMode({ + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 5}], + selectionMode: true + })).toEqual({type: 'contentElement', id: 5}); + }); +}); diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.js index 6c7d4fe3d3..5f5e249e7e 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.js @@ -9,7 +9,7 @@ import {useI18n} from '../i18n'; import {api} from '../api'; import {widths} from '../layouts'; import {SelectionRect} from './SelectionRect'; -import {hasSelectionModifier} from './multiSelection'; +import {useSelectionMode} from './EditorState'; import {DropTargets} from './DropTargets'; import {MarginIndicator} from './MarginIndicator'; import { @@ -57,6 +57,7 @@ function DefaultSelectionRect(props) { const commentsSelected = type === 'contentElementComments' || type === 'newThread'; const {resolution, alwaysShowComments} = useCommentDisplayFilter(); const {t} = useI18n({locale: 'ui'}); + const selectionMode = useSelectionMode(); const selectionRectRef = useRef(); @@ -101,9 +102,9 @@ function DefaultSelectionRect(props) { commentBadgeInset={!isSelected} ariaLabel={t('pageflow_scrolled.inline_editing.select_content_element')} insertButtonTitles={t('pageflow_scrolled.inline_editing.insert_content_element')} - onMouseDown={event => hasSelectionModifier(event) && + onMouseDown={event => selectionMode.extendsSelection(event) && event.preventDefault()} - onClick={event => hasSelectionModifier(event) ? + onClick={event => selectionMode.extendsSelection(event) ? extendSelection() : select()} onInsertButtonClick={at => diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.module.css b/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.module.css index 2280dad77d..db95c4f448 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.module.css +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.module.css @@ -3,6 +3,8 @@ } :global([data-selection-modifier]) .wrapper, -:global([data-selection-modifier]) .wrapper * { +:global([data-selection-modifier]) .wrapper *, +:global([data-selection-mode]) .wrapper, +:global([data-selection-mode]) .wrapper * { cursor: copy !important; } diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/useMultiSelectHandler.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/useMultiSelectHandler.js index c84ea70f50..114f6a9f00 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/useMultiSelectHandler.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/useMultiSelectHandler.js @@ -1,13 +1,14 @@ import {useCallback} from 'react'; -import {hasSelectionModifier} from '../multiSelection'; import {useContentElementEditorState} from '../../useContentElementEditorState'; +import {useSelectionMode} from '../EditorState'; export function useMultiSelectHandler() { const {extendSelection} = useContentElementEditorState(); + const selectionMode = useSelectionMode(); return useCallback(event => { - if (!hasSelectionModifier(event)) { + if (!selectionMode.extendsSelection(event)) { return; } @@ -15,5 +16,5 @@ export function useMultiSelectHandler() { // would edit a content element that is part of a multi selection. event.preventDefault(); extendSelection(); - }, [extendSelection]); + }, [extendSelection, selectionMode]); } diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditorState.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditorState.js index 0a8434cbb2..8c33bfb4a8 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditorState.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditorState.js @@ -1,6 +1,12 @@ import React, {useState, useContext, useMemo, useCallback, useRef} from 'react'; -import {extendMultiSelection, isInMultiSelection} from './multiSelection'; +import { + extendMultiSelection, + hasSelectionModifier, + isInMultiSelection, + isInSelectionMode, + withoutSelectionMode +} from './multiSelection'; const Context = React.createContext({}); @@ -27,9 +33,15 @@ export function EditorStateProvider(props) { setSelection(extendMultiSelection(selectionRef.current, options)); }, [setSelection]); + const endSelectionMode = useCallback(() => { + if (isInSelectionMode(selectionRef.current)) { + setSelection(withoutSelectionMode(selectionRef.current)); + } + }, [setSelection]); + const value = useMemo(() => ({ - selection, setSelection, extendSelection - }), [setSelection, extendSelection, selection]) + selection, setSelection, extendSelection, endSelectionMode + }), [setSelection, extendSelection, endSelectionMode, selection]) return ( @@ -73,3 +85,14 @@ export function useEditorSelection(options) { }; }, [options, selection, setSelection, select, extendSelection, resetSelection]); } + +export function useSelectionMode() { + const {selection, endSelectionMode} = useContext(Context); + const active = isInSelectionMode(selection); + + return useMemo(() => ({ + active, + end: endSelectionMode, + extendsSelection: event => active || hasSelectionModifier(event) + }), [active, endSelectionMode]); +} diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EntryDecorator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EntryDecorator.js index 638c17c359..5a4bca338e 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EntryDecorator.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EntryDecorator.js @@ -7,7 +7,7 @@ import { } from 'pageflow-scrolled/review'; import {useEntryStateDispatch} from 'pageflow-scrolled/entryState'; import {usePostMessageListener} from '../../shared/usePostMessageListener'; -import {EditorStateProvider, useEditorSelection} from './EditorState'; +import {EditorStateProvider, useEditorSelection, useSelectionMode} from './EditorState'; import {watchSelectionModifier} from './multiSelection'; import { useContentElementEditorCommandEmitter, @@ -23,6 +23,7 @@ export function EntryDecorator({commentingInitialState, children}) { + @@ -56,8 +57,37 @@ function CommentDisplayFilterFromEditor({children}) { ); } +function SelectionModeHandler() { + const {active, end} = useSelectionMode(); + + useEffect(() => { + if (!active) { + return; + } + + const root = document.documentElement; + + function handleKeyDown(event) { + if (event.key === 'Escape') { + end(); + } + } + + root.setAttribute('data-selection-mode', ''); + window.addEventListener('keydown', handleKeyDown); + + return () => { + root.removeAttribute('data-selection-mode'); + window.removeEventListener('keydown', handleKeyDown); + }; + }, [active, end]); + + return null; +} + function MessageHandler({contentElementEditorCommandEmitter}) { const {select} = useEditorSelection() + const {end: endSelectionMode} = useSelectionMode(); const dispatch = useEntryStateDispatch(); const receiveMessage = useCallback(data => { @@ -67,11 +97,14 @@ function MessageHandler({contentElementEditorCommandEmitter}) { else if (data.type === 'SELECT') { select(data.payload); } + else if (data.type === 'END_SELECTION_MODE') { + endSelectionMode(); + } else if (data.type === 'CONTENT_ELEMENT_EDITOR_COMMAND') { contentElementEditorCommandEmitter.trigger(`command:${data.payload.contentElementId}`, data.payload.command); } - }, [dispatch, select, contentElementEditorCommandEmitter]); + }, [dispatch, select, endSelectionMode, contentElementEditorCommandEmitter]); usePostMessageListener(receiveMessage); diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/multiSelection.js b/entry_types/scrolled/package/src/frontend/inlineEditing/multiSelection.js index 495242c87a..64a52320a7 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/multiSelection.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/multiSelection.js @@ -5,6 +5,16 @@ // itemType: 'contentElement', // items: [{id: 5}, {id: 6}] // } +// +// In selection mode, started from the editor, plain clicks extend the +// selection as well and it stays a multi selection down to one item: +// +// { +// type: 'multiSelection', +// itemType: 'contentElement', +// items: [{id: 5}], +// selectionMode: true +// } export function hasSelectionModifier(event) { return event.ctrlKey || event.metaKey; @@ -52,6 +62,18 @@ export function extendMultiSelection(selection, {type, id}) { return {type, id}; } +export function isInSelectionMode(selection) { + return !!selection && !!selection.selectionMode; +} + +export function withoutSelectionMode({selectionMode, ...selection}) { + if (selection.items.length > 1) { + return selection; + } + + return {type: selection.itemType, id: selection.items[0].id}; +} + export function isInMultiSelection(selection, {type, id}) { return isMultiSelectionOf(selection, type) && selection.items.some(item => item.id === id); @@ -66,6 +88,10 @@ function isMultiSelectionOf(selection, type) { function withoutItem(selection, id) { const items = selection.items.filter(item => item.id !== id); + if (selection.selectionMode) { + return items.length ? {...selection, items} : selection; + } + if (items.length > 1) { return {...selection, items}; } From 62e40ce48b573a41199376967ffcbedb5663b85c Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Thu, 29 Oct 2026 11:02:34 +0100 Subject: [PATCH 09/15] Select multiple content elements from actions menu Selecting several content elements required knowing about clicking them with the Ctrl or Cmd key held. Offer an item in the actions menu of a content element that starts the selection mode in the preview instead. While it is active, the sidebar explains how to add content elements and offers a button to end the mode. --- entry_types/scrolled/config/locales/de.yml | 4 + entry_types/scrolled/config/locales/en.yml | 4 + .../PreviewMessageController-spec.js | 79 +++++++++++++++++++ .../controllers/SideBarController-spec.js | 24 ++++++ .../models/contentElementMenuItems-spec.js | 34 +++++++- .../views/EditContentElementView-spec.js | 16 +++- .../editor/views/MultiSelectionView-spec.js | 56 ++++++++++++- .../controllers/PreviewMessageController.js | 20 ++++- .../editor/controllers/SideBarController.js | 5 +- .../editor/models/contentElementMenuItems.js | 14 ++++ .../editor/views/EditContentElementView.js | 7 +- .../src/editor/views/MultiSelectionView.js | 24 +++++- .../views/MultiSelectionView.module.css | 11 +++ 13 files changed, 286 insertions(+), 12 deletions(-) diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index da1da87a16..2147269e69 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1567,6 +1567,8 @@ de: header_insertPosition: Abschnitt verschieben header_sectionPart: Element verschieben hint: Wähle die Position aus, an die verschoben werden soll. + select_multiple_content_elements_menu_item: + label: "Mehrere auswählen..." selectable_storyline_item: blank_slate: Keine Kapitel blank_slate_excursions: Keine Exkurse @@ -1817,6 +1819,8 @@ de: tabs: activity: Letzte Aktivität multi_selection_view: + end_selection_mode: Fertig + selection_mode_hint: Elemente in der Vorschau anklicken, um sie zur Auswahl hinzuzufügen oder daraus zu entfernen. tab: one: 1 Element other: "%{count} Elemente" diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 046ac34149..e7e98c027c 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1549,6 +1549,8 @@ en: header_insertPosition: Move section header_sectionPart: Move element hint: Select the position to move to. + select_multiple_content_elements_menu_item: + label: "Select multiple..." selectable_storyline_item: blank_slate: No chapters blank_slate_excursions: No excursions @@ -1797,6 +1799,8 @@ en: tabs: activity: Latest activity multi_selection_view: + end_selection_mode: Done + selection_mode_hint: Click elements in the preview to add them to or remove them from the selection. tab: one: 1 Element other: "%{count} Elements" diff --git a/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js b/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js index 8d00251a92..5169286234 100644 --- a/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js +++ b/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js @@ -625,6 +625,85 @@ describe('PreviewMessageController', () => { ); }); + it('sends SELECT message for selection mode on startContentElementSelection event', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1}] + }) + }); + const iframeWindow = createSendingIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow}); + + await postReadyMessageAndWaitForAcknowledgement(iframeWindow); + + return expect(new Promise(resolve => { + iframeWindow.addEventListener('message', event => { + if (event.data.type === 'SELECT') { + resolve(event.data); + } + }); + entry.trigger('startContentElementSelection', entry.contentElements.first()); + })).resolves.toMatchObject({ + type: 'SELECT', + payload: { + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 1}], + selectionMode: true + } + }); + }); + + it('sends END_SELECTION_MODE message on endSelectionMode event', async () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({}) + }); + const iframeWindow = createSendingIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow}); + + await postReadyMessageAndWaitForAcknowledgement(iframeWindow); + + return expect(new Promise(resolve => { + iframeWindow.addEventListener('message', event => { + if (event.data.type === 'END_SELECTION_MODE') { + resolve(event.data); + } + }); + entry.trigger('endSelectionMode'); + })).resolves.toMatchObject({type: 'END_SELECTION_MODE'}); + }); + + it('passes selection mode to multi selection route', () => { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + contentElements: [{id: 1}] + }) + }); + const iframeWindow = createSendingIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow, editor}); + + return expect(new Promise(resolve => { + editor.on('navigate', resolve); + window.postMessage({ + type: 'SELECTED', + payload: { + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: 1}], + selectionMode: true + } + }, '*'); + })).resolves.toBe( + '/scrolled/multi_selection?payload=' + + encodeURIComponent(JSON.stringify({ + itemType: 'contentElement', + items: [{id: 1}], + selectionMode: true + })) + ); + }); + it('navigates to edit section route on SELECTED message for section settings', () => { const editor = factories.editorApi(); const entry = factories.entry(ScrolledEntry, {}, { diff --git a/entry_types/scrolled/package/spec/editor/controllers/SideBarController-spec.js b/entry_types/scrolled/package/spec/editor/controllers/SideBarController-spec.js index 09cbc2004f..8e26c66091 100644 --- a/entry_types/scrolled/package/spec/editor/controllers/SideBarController-spec.js +++ b/entry_types/scrolled/package/spec/editor/controllers/SideBarController-spec.js @@ -3,6 +3,7 @@ import 'editor/config'; import {SideBarController} from 'editor/controllers/SideBarController'; import {CommentsView} from 'editor/views/CommentsView'; import {CommentActivityView} from 'editor/views/CommentActivityView'; +import {MultiSelectionView} from 'editor/views/MultiSelectionView'; import {factories} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; @@ -38,6 +39,29 @@ describe('SideBarController', () => { }); }); + describe('#multiSelection', () => { + it('passes items and selection mode to MultiSelectionView', () => { + const entry = createEntry({}); + + const region = {show: jest.fn()}; + const controller = new SideBarController({region, entry}); + + controller.multiSelection(encodeURIComponent(JSON.stringify({ + itemType: 'contentElement', + items: [{id: 1}], + selectionMode: true + }))); + + const shown = region.show.mock.calls[0][0]; + expect(shown).toBeInstanceOf(MultiSelectionView); + expect(shown.options).toMatchObject({ + itemType: 'contentElement', + items: [{id: 1}], + selectionMode: true + }); + }); + }); + describe('#commentActivity', () => { it('shows a CommentActivityView in the region', () => { const entry = createEntry({}); 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 ef5f1f6b94..0d0c63f4c3 100644 --- a/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/contentElementMenuItems-spec.js @@ -3,7 +3,8 @@ import { DestroyContentElementsMenuItem, DuplicateContentElementMenuItem, ExtractContentElementsFragmentMenuItem, - MoveContentElementMenuItem + MoveContentElementMenuItem, + SelectMultipleContentElementsMenuItem } from 'editor/models/contentElementMenuItems'; import {ScrolledEntry} from 'editor/models/ScrolledEntry'; import {SelectMoveDestinationDialogView} from 'editor/views/SelectMoveDestinationDialogView'; @@ -614,4 +615,35 @@ describe('ContentElementMenuItems', () => { app.off('error'); }); }); + + describe('SelectMultipleContentElementsMenuItem', () => { + useFakeTranslations({ + 'pageflow_scrolled.editor.select_multiple_content_elements_menu_item.label': + 'Select multiple' + }); + + it('has Select multiple label', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({contentElements: [{id: 1}]}) + }); + const contentElement = entry.contentElements.get(1); + + const menuItem = new SelectMultipleContentElementsMenuItem({}, {entry, contentElement}); + + expect(menuItem.get('label')).toBe('Select multiple'); + }); + + it('starts content element selection when selected', () => { + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({contentElements: [{id: 1}]}) + }); + const contentElement = entry.contentElements.get(1); + const listener = jest.fn(); + entry.on('startContentElementSelection', listener); + + new SelectMultipleContentElementsMenuItem({}, {entry, contentElement}).selected(); + + expect(listener).toHaveBeenCalledWith(contentElement); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/editor/views/EditContentElementView-spec.js b/entry_types/scrolled/package/spec/editor/views/EditContentElementView-spec.js index 06a8cc2206..c62cff5e8a 100644 --- a/entry_types/scrolled/package/spec/editor/views/EditContentElementView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EditContentElementView-spec.js @@ -74,7 +74,21 @@ describe('EditContentElementView', () => { useFakeTranslations({ 'pageflow_scrolled.editor.extract_content_elements_fragment_menu_item.label': - 'Add to shared fragments' + 'Add to shared fragments', + 'pageflow_scrolled.editor.select_multiple_content_elements_menu_item.label': + 'Select multiple' + }); + + it('offers selecting multiple content elements', () => { + const view = createView(); + const listener = jest.fn(); + view.options.entry.on('startContentElementSelection', listener); + + view.getActionsMenuItems() + .find(item => item.get('label') === 'Select multiple') + .selected(); + + expect(listener).toHaveBeenCalledWith(view.model); }); function createView() { diff --git a/entry_types/scrolled/package/spec/editor/views/MultiSelectionView-spec.js b/entry_types/scrolled/package/spec/editor/views/MultiSelectionView-spec.js index 105a9bae84..fff4428b4d 100644 --- a/entry_types/scrolled/package/spec/editor/views/MultiSelectionView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/MultiSelectionView-spec.js @@ -27,14 +27,18 @@ describe('MultiSelectionView', () => { 'pageflow_scrolled.editor.extract_content_elements_fragment_menu_item.label': 'Add to shared fragments', 'pageflow.editor.templates.back_button_decorator.outline': 'Outline', - 'pageflow.editor.views.edit_configuration.actions': 'Actions' + 'pageflow.editor.views.edit_configuration.actions': 'Actions', + 'pageflow_scrolled.editor.multi_selection_view.selection_mode_hint': + 'Click elements in the preview to add them.', + 'pageflow_scrolled.editor.multi_selection_view.end_selection_mode': 'Done' }); - function buildView(entry, items) { + function buildView(entry, items, options) { return new MultiSelectionView({ entry, itemType: 'contentElement', - items + items, + ...options }); } @@ -175,4 +179,50 @@ describe('MultiSelectionView', () => { .toHaveBeenCalledWith(items, {title: 'Quote'}); }); }); + + it('does not render selection mode hint by default', () => { + const entry = createEntry({ + contentElements: [{id: 1, typeName: 'heading'}, {id: 2, typeName: 'heading'}] + }); + + const {queryByRole} = renderBackboneView(buildView(entry, [{id: 1}, {id: 2}])); + + expect(queryByRole('button', {name: 'Done'})).toBeNull(); + }); + + it('renders selection mode hint in selection mode', () => { + const entry = createEntry({ + contentElements: [{id: 1, typeName: 'heading'}] + }); + + const {getByText} = renderBackboneView(buildView(entry, [{id: 1}], {selectionMode: true})); + + expect(getByText('Click elements in the preview to add them.')).toBeInTheDocument(); + }); + + it('renders selection mode hint below tab bar', () => { + const entry = createEntry({ + contentElements: [{id: 1, typeName: 'heading'}] + }); + + const {getByRole, getByText} = + renderBackboneView(buildView(entry, [{id: 1}], {selectionMode: true})); + + expect(getByRole('tablist').compareDocumentPosition( + getByText('Click elements in the preview to add them.') + ) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('ends selection mode when done button is clicked', () => { + const entry = createEntry({ + contentElements: [{id: 1, typeName: 'heading'}] + }); + const listener = jest.fn(); + entry.on('endSelectionMode', listener); + + const {getByRole} = renderBackboneView(buildView(entry, [{id: 1}], {selectionMode: true})); + fireEvent.click(getByRole('button', {name: 'Done'})); + + expect(listener).toHaveBeenCalled(); + }); }); diff --git a/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js b/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js index e16e97f6d6..bbaec8a84f 100644 --- a/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js +++ b/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js @@ -177,6 +177,22 @@ export const PreviewMessageController = Object.extend({ }) ); + this.listenTo(this.entry, 'startContentElementSelection', contentElement => + postMessage({ + type: 'SELECT', + payload: { + type: 'multiSelection', + itemType: 'contentElement', + items: [{id: contentElement.id}], + selectionMode: true + } + }) + ); + + this.listenTo(this.entry, 'endSelectionMode', () => + postMessage({type: 'END_SELECTION_MODE'}) + ); + this.listenTo(this.entry.commentDisplayFilter, 'change:resolution change:alwaysShowComments', filter => @@ -246,8 +262,8 @@ export const PreviewMessageController = Object.extend({ } } else if (type === 'multiSelection') { - const {itemType, items} = message.data.payload; - const payload = encodeURIComponent(JSON.stringify({itemType, items})); + const {itemType, items, selectionMode} = message.data.payload; + const payload = encodeURIComponent(JSON.stringify({itemType, items, selectionMode})); this.editor.navigate(`/scrolled/multi_selection?payload=${payload}`, {trigger: true}) } else if (type === 'sectionSettings') { diff --git a/entry_types/scrolled/package/src/editor/controllers/SideBarController.js b/entry_types/scrolled/package/src/editor/controllers/SideBarController.js index e663da0369..7524c5d5e3 100644 --- a/entry_types/scrolled/package/src/editor/controllers/SideBarController.js +++ b/entry_types/scrolled/package/src/editor/controllers/SideBarController.js @@ -82,12 +82,13 @@ export const SideBarController = Marionette.Controller.extend({ }, multiSelection: function(payload) { - const {itemType, items} = JSON.parse(decodeURIComponent(payload)); + const {itemType, items, selectionMode} = JSON.parse(decodeURIComponent(payload)); this.region.show(new MultiSelectionView({ entry: this.entry, itemType, - items + items, + selectionMode })); }, diff --git a/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js index f2d74786bd..be55c4135a 100644 --- a/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/contentElementMenuItems.js @@ -100,6 +100,20 @@ export const ExtractContentElementsFragmentMenuItem = ExtractFragmentMenuItem.ex } }); +export const SelectMultipleContentElementsMenuItem = Backbone.Model.extend({ + initialize(attributes, {entry, contentElement}) { + this.entry = entry; + this.contentElement = contentElement; + this.set('label', I18n.t( + 'pageflow_scrolled.editor.select_multiple_content_elements_menu_item.label' + )); + }, + + selected() { + this.entry.trigger('startContentElementSelection', this.contentElement); + } +}); + export const MoveContentElementMenuItem = Backbone.Model.extend({ initialize(attributes, options) { this.contentElement = options.contentElement; diff --git a/entry_types/scrolled/package/src/editor/views/EditContentElementView.js b/entry_types/scrolled/package/src/editor/views/EditContentElementView.js index 53257cbc7a..f6dc15afdf 100644 --- a/entry_types/scrolled/package/src/editor/views/EditContentElementView.js +++ b/entry_types/scrolled/package/src/editor/views/EditContentElementView.js @@ -5,7 +5,8 @@ import { DestroyContentElementMenuItem, DuplicateContentElementMenuItem, ExtractContentElementsFragmentMenuItem, - MoveContentElementMenuItem + MoveContentElementMenuItem, + SelectMultipleContentElementsMenuItem } from '../models/contentElementMenuItems'; export const EditContentElementView = EditConfigurationView.extend({ @@ -37,6 +38,10 @@ export const EditContentElementView = EditConfigurationView.extend({ entry: this.options.entry, editor: this.options.editor }), + new SelectMultipleContentElementsMenuItem({separated: true}, { + contentElement: this.model, + entry: this.options.entry + }), ...(features.isEnabled('fragments') ? [new ExtractContentElementsFragmentMenuItem({}, { entry: this.options.entry, diff --git a/entry_types/scrolled/package/src/editor/views/MultiSelectionView.js b/entry_types/scrolled/package/src/editor/views/MultiSelectionView.js index 194bfa9ceb..12bb176dc1 100644 --- a/entry_types/scrolled/package/src/editor/views/MultiSelectionView.js +++ b/entry_types/scrolled/package/src/editor/views/MultiSelectionView.js @@ -22,16 +22,29 @@ export const MultiSelectionView = Marionette.ItemView.extend({ ${I18n.t('pageflow.editor.templates.back_button_decorator.outline')}
+
+ ${I18n.t('pageflow_scrolled.editor.multi_selection_view.selection_mode_hint')} + +
`, - ui: cssModulesUtils.ui(styles, 'actions', 'tabs'), + ui: cssModulesUtils.ui(styles, 'actions', 'tabs', 'selectionModeHint'), events: { - 'click a.back': 'goBack' + 'click a.back': 'goBack', + + ...cssModulesUtils.events(styles, { + 'click endSelectionMode': function() { + this.options.entry.trigger('endSelectionMode'); + } + }) }, onRender() { const {entry, items} = this.options; + const contentElements = sortByDocumentOrder( entry, items @@ -62,6 +75,13 @@ export const MultiSelectionView = Marionette.ItemView.extend({ }); this.appendSubview(tabsView, {to: this.ui.tabs}); + + if (this.options.selectionMode) { + this.ui.tabs.find('.tabs_view-container').first().before(this.ui.selectionModeHint); + } + else { + this.ui.selectionModeHint.remove(); + } }, // Resetting the selection in the preview makes it navigate back to diff --git a/entry_types/scrolled/package/src/editor/views/MultiSelectionView.module.css b/entry_types/scrolled/package/src/editor/views/MultiSelectionView.module.css index 3fd82021f0..e900d1b302 100644 --- a/entry_types/scrolled/package/src/editor/views/MultiSelectionView.module.css +++ b/entry_types/scrolled/package/src/editor/views/MultiSelectionView.module.css @@ -2,6 +2,17 @@ padding-bottom: space(2); } +.selectionModeHint { + composes: banner from './banner.module.css'; + margin: space(3) 0; +} + +.endSelectionMode { + composes: primaryIconButton compactButton from './buttons.module.css'; + composes: check from './icons.module.css'; + align-self: flex-end; +} + .actions { float: right; } From 0f95185aad48bd3cf704e7bc7f8b10dd9660a533 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 30 Oct 2026 11:26:32 +0100 Subject: [PATCH 10/15] Remove adding chapters to shared fragments Selecting sections in the outline covers adding a whole chapter while also allowing to pick sections across chapters. Drop the chapter menu item together with the endpoint behind it. --- .../editor/fragment_extractions_controller.rb | 24 -- .../pageflow_scrolled/fragment_library.rb | 19 -- entry_types/scrolled/config/locales/de.yml | 5 - entry_types/scrolled/config/locales/en.yml | 5 - entry_types/scrolled/config/routes.rb | 1 - .../spec/editor/models/Chapter-spec.js | 46 --- .../editor/models/chapterMenuItems-spec.js | 112 ------- .../spec/editor/views/EditChapterView-spec.js | 39 +-- .../package/src/editor/models/Chapter.js | 8 - .../src/editor/models/chapterMenuItems.js | 42 +-- .../src/editor/views/EditChapterView.js | 5 - .../fragment_extractions_controller_spec.rb | 298 ------------------ ...on_fragment_extractions_controller_spec.rb | 34 ++ .../fragment_library_spec.rb | 67 ++-- 14 files changed, 72 insertions(+), 633 deletions(-) delete mode 100644 entry_types/scrolled/app/controllers/pageflow_scrolled/editor/fragment_extractions_controller.rb delete mode 100644 entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_extractions_controller_spec.rb diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/fragment_extractions_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/fragment_extractions_controller.rb deleted file mode 100644 index 186a7adf75..0000000000 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/fragment_extractions_controller.rb +++ /dev/null @@ -1,24 +0,0 @@ -module PageflowScrolled - module Editor - # @api private - class FragmentExtractionsController < ActionController::Base - include Pageflow::EditorController - - before_action do - head :not_found unless @entry.feature_state('fragments') - end - - def create - chapter = Chapter.all_for_revision(@entry.draft).find(params[:chapter_id]) - library = FragmentLibrary.shared_for_account(@entry.account) - - authorize!(:update, library) - library.extract_fragment_from(entry: @entry, chapter:, title: params[:title]) - - head :created - rescue ActiveRecord::RecordNotFound - head :not_found - end - end - end -end diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb b/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb index 33f2c5d9c5..5d037d0386 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb @@ -57,16 +57,6 @@ def prepare_content_elements_for(fragment_perma_id:, entry:) end end - def extract_fragment_from(entry:, chapter:, title: nil) - ActiveRecord::Base.transaction do - @entry ||= create_entry(locale: entry.locale) - - SectionsCopy - .new(source_entry: entry, destination_entry: self.entry) - .perform(chapter.sections, chapter: create_fragment_chapter(chapter, title)) - end - end - def extract_sections_from(entry:, sections:, title: nil) ActiveRecord::Base.transaction do @entry ||= create_entry(locale: entry.locale) @@ -117,10 +107,6 @@ def create_entry(locale:) ) end - def create_fragment_chapter(chapter, title) - create_chapter(fragment_configuration(chapter, title)) - end - def create_content_elements_fragment_section(title) chapter = create_chapter({ 'title' => title.presence&.strip, @@ -137,11 +123,6 @@ def create_chapter(configuration) configuration:) end - def fragment_configuration(chapter, title) - title = title.presence&.strip - title ? chapter.configuration.merge('title' => title) : chapter.configuration - end - # @api private class Query include Enumerable diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 2147269e69..da748bd031 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1287,11 +1287,6 @@ de: save: Speichern chapter_menu_items: copy_permalink: Permalink kopieren - extract_fragment: Zu geteilten Fragmenten hinzufügen... - extract_fragment_failed: Das Kapitel konnte nicht zu den geteilten Fragmenten hinzugefügt werden. - extract_fragment_forbidden: Sie dürfen den geteilten Fragmenten dieses Kontos keine Kapitel hinzufügen. - extracted_fragment: Zu geteilten Fragmenten hinzugefügt - extracting_fragment: Wird zu geteilten Fragmenten hinzugefügt... move_to_main: In Kapitel umwandeln move_to_excursions: In Exkurs umwandeln destroy_chapter_menu_item: diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index e7e98c027c..43ed4baf57 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1269,11 +1269,6 @@ en: save: Save chapter_menu_items: copy_permalink: Copy permalink - extract_fragment: Add to shared fragments... - extract_fragment_failed: The chapter could not be added to the shared fragments. - extract_fragment_forbidden: You are not allowed to add chapters to the shared fragments of this account. - extracted_fragment: Added to shared fragments - extracting_fragment: Adding to shared fragments... move_to_main: Turn into chapter move_to_excursions: Turn into excursion destroy_chapter_menu_item: diff --git a/entry_types/scrolled/config/routes.rb b/entry_types/scrolled/config/routes.rb index bb5b41c5cb..7772c149b0 100644 --- a/entry_types/scrolled/config/routes.rb +++ b/entry_types/scrolled/config/routes.rb @@ -31,7 +31,6 @@ end resources :fragment_insertions, only: [:create] - resources :fragment_extractions, only: [:create] resources :sections, only: [:create, :update, :destroy] do collection do 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 4f01008f3a..4d0df4758c 100644 --- a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js @@ -440,52 +440,6 @@ describe('Chapter', () => { }); }); - describe('#extractToFragmentLibrary', () => { - beforeEach(() => { - testContext.entry = factories.entry(ScrolledEntry, {id: 1}, { - entryTypeSeed: normalizeSeed({ - chapters: [{id: 10}] - }) - }); - }); - - setupGlobals({ - entry: () => testContext.entry - }); - - useFakeXhr(() => testContext); - - it('posts to fragment extractions of chapter', () => { - const {entry, requests} = testContext; - - entry.chapters.first().extractToFragmentLibrary(); - - expect(requests[0].method).toBe('POST'); - expect(requests[0].url) - .toBe('/editor/entries/1/scrolled/chapters/10/fragment_extractions'); - }); - - it('posts given title', () => { - const {entry, requests} = testContext; - - entry.chapters.first().extractToFragmentLibrary({title: 'Opening'}); - - expect(requests[0].requestBody).toContain('title=Opening'); - }); - - it('resolves once library responds without body', async () => { - const {entry, server} = testContext; - - const extraction = entry.chapters.first().extractToFragmentLibrary(); - server.respond( - 'POST', '/editor/entries/1/scrolled/chapters/10/fragment_extractions', - [201, {}, ''] - ); - - await expect(extraction).resolves.toBeUndefined(); - }); - }); - describe('#moveSection', () => { describe('within same chapter', () => { beforeEach(() => { diff --git a/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js b/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js index 11cbb5ea72..966a36e727 100644 --- a/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/chapterMenuItems-spec.js @@ -1,13 +1,9 @@ import { CopyPermalinkMenuItem, - ExtractFragmentMenuItem, ToggleExcursionMenuItem, DestroyChapterMenuItem } from 'editor/models/chapterMenuItems'; -import {ExtractFragmentDialogView} from 'editor/views/ExtractFragmentDialogView'; - -import {app} from 'pageflow/editor'; import {useFakeTranslations} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; @@ -16,11 +12,6 @@ describe('ChapterMenuItems', () => { 'pageflow_scrolled.editor.chapter_menu_items.copy_permalink': 'Copy permalink', 'pageflow_scrolled.editor.chapter_menu_items.move_to_main': 'Move to main', 'pageflow_scrolled.editor.chapter_menu_items.move_to_excursions': 'Move to excursions', - 'pageflow_scrolled.editor.chapter_menu_items.extract_fragment': 'Add to shared fragments', - 'pageflow_scrolled.editor.chapter_menu_items.extracting_fragment': 'Adding to shared fragments...', - 'pageflow_scrolled.editor.chapter_menu_items.extracted_fragment': 'Added to shared fragments', - 'pageflow_scrolled.editor.chapter_menu_items.extract_fragment_failed': 'Could not add chapter.', - 'pageflow_scrolled.editor.chapter_menu_items.extract_fragment_forbidden': 'Not allowed.', 'pageflow_scrolled.editor.destroy_chapter_menu_item.destroy': 'Delete chapter', 'pageflow_scrolled.editor.destroy_chapter_menu_item.confirm_destroy': 'Really delete this chapter?' }); @@ -90,109 +81,6 @@ describe('ChapterMenuItems', () => { }); }); - describe('ExtractFragmentMenuItem', () => { - beforeEach(() => { - jest.spyOn(ExtractFragmentDialogView, 'show').mockImplementation(() => {}); - }); - - afterEach(() => jest.restoreAllMocks()); - - function createMenuItem(extraction, configuration = {}) { - const entry = createEntry({chapters: [{id: 1, configuration}]}); - const chapter = entry.chapters.get(1); - chapter.extractToFragmentLibrary = jest.fn().mockReturnValue(extraction); - - return {chapter, menuItem: new ExtractFragmentMenuItem({}, {chapter})}; - } - - function pendingExtraction() { - return new Promise(() => {}); - } - - function submitName(name) { - return ExtractFragmentDialogView.show.mock.calls[0][0].onSubmit(name); - } - - it('has Add to shared fragments label', () => { - const {menuItem} = createMenuItem(pendingExtraction()); - - expect(menuItem.get('label')).toBe('Add to shared fragments'); - }); - - it('opens name dialog prefilled with chapter title when selected', () => { - const {menuItem} = createMenuItem(pendingExtraction(), {title: 'Intro'}); - - menuItem.selected(); - - expect(ExtractFragmentDialogView.show) - .toHaveBeenCalledWith(expect.objectContaining({title: 'Intro'})); - }); - - it('extracts chapter with submitted name', () => { - const {chapter, menuItem} = createMenuItem(pendingExtraction()); - - menuItem.selected(); - submitName('Opening with video'); - - expect(chapter.extractToFragmentLibrary) - .toHaveBeenCalledWith({title: 'Opening with video'}); - }); - - it('does not extract chapter before name is submitted', () => { - const {chapter, menuItem} = createMenuItem(pendingExtraction()); - - menuItem.selected(); - - expect(chapter.extractToFragmentLibrary).not.toHaveBeenCalled(); - }); - - it('is disabled while extraction is pending', () => { - const {menuItem} = createMenuItem(pendingExtraction()); - - menuItem.selected(); - submitName('Intro'); - - expect(menuItem.get('label')).toBe('Adding to shared fragments...'); - expect(menuItem.get('disabled')).toBe(true); - }); - - it('stays disabled once chapter has been added', async () => { - const {menuItem} = createMenuItem(Promise.resolve()); - - menuItem.selected(); - await submitName('Intro'); - - expect(menuItem.get('label')).toBe('Added to shared fragments'); - expect(menuItem.get('disabled')).toBe(true); - }); - - it('is enabled again and reports error when extraction fails', async () => { - const {menuItem} = createMenuItem(Promise.reject({status: 500})); - const errors = []; - app.on('error', error => errors.push(error.message)); - - menuItem.selected(); - await submitName('Intro'); - - expect(menuItem.get('label')).toBe('Add to shared fragments'); - expect(menuItem.get('disabled')).toBe(false); - expect(errors).toEqual(['Could not add chapter.']); - app.off('error'); - }); - - it('reports missing permission', async () => { - const {menuItem} = createMenuItem(Promise.reject({status: 403})); - const errors = []; - app.on('error', error => errors.push(error.message)); - - menuItem.selected(); - await submitName('Intro'); - - expect(errors).toEqual(['Not allowed.']); - app.off('error'); - }); - }); - describe('DestroyChapterMenuItem', () => { it('has Delete chapter label', () => { const entry = createEntry({chapters: [{id: 1}]}); diff --git a/entry_types/scrolled/package/spec/editor/views/EditChapterView-spec.js b/entry_types/scrolled/package/spec/editor/views/EditChapterView-spec.js index c16fe9439e..4a202949b5 100644 --- a/entry_types/scrolled/package/spec/editor/views/EditChapterView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EditChapterView-spec.js @@ -1,6 +1,6 @@ import {EditChapterView} from 'editor/views/EditChapterView'; -import {ConfigurationEditor, useFakeFeatures, useFakeTranslations} from 'pageflow/testHelpers'; +import {ConfigurationEditor, useFakeTranslations} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; describe('EditChapterView', () => { @@ -8,42 +8,7 @@ describe('EditChapterView', () => { useFakeTranslations({ 'pageflow_scrolled.editor.edit_chapter.tabs.chapter': 'Chapter', - 'pageflow_scrolled.editor.edit_chapter.tabs.excursion': 'Excursion', - 'pageflow_scrolled.editor.chapter_menu_items.extract_fragment': 'Add to shared fragments' - }); - - it('does not offer adding chapter to shared fragments without fragments feature', () => { - const entry = createEntry({ - storylines: [{id: 100, role: 'main'}], - chapters: [{id: 1, storylineId: 100}] - }); - - const view = new EditChapterView({ - model: entry.chapters.get(1), - entry - }); - - expect(view.getActionsMenuItems().map(item => item.get('label'))) - .not.toContain('Add to shared fragments'); - }); - - describe('with fragments feature', () => { - useFakeFeatures('editor', ['fragments']); - - it('offers adding chapter to shared fragments', () => { - const entry = createEntry({ - storylines: [{id: 100, role: 'main'}], - chapters: [{id: 1, storylineId: 100}] - }); - - const view = new EditChapterView({ - model: entry.chapters.get(1), - entry - }); - - expect(view.getActionsMenuItems().map(item => item.get('label'))) - .toContain('Add to shared fragments'); - }); + 'pageflow_scrolled.editor.edit_chapter.tabs.excursion': 'Excursion' }); it('uses chapter tab for main storyline chapters', () => { diff --git a/entry_types/scrolled/package/src/editor/models/Chapter.js b/entry_types/scrolled/package/src/editor/models/Chapter.js index ae4b679724..87692680db 100644 --- a/entry_types/scrolled/package/src/editor/models/Chapter.js +++ b/entry_types/scrolled/package/src/editor/models/Chapter.js @@ -140,14 +140,6 @@ export const Chapter = Backbone.Model.extend({ }); }, - extractToFragmentLibrary({title} = {}) { - return Promise.resolve($.ajax({ - url: `${this.url()}/fragment_extractions`, - type: 'POST', - data: {title} - })).then(() => undefined); - }, - addFragmentSections(sectionsAttributes) { const sections = sectionsAttributes.map(({contentElements, ...attributes}) => { this.sections.add(attributes, {contentElements: this.entry.contentElements}); diff --git a/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js b/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js index 100febdc6d..5d41892786 100644 --- a/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js +++ b/entry_types/scrolled/package/src/editor/models/chapterMenuItems.js @@ -1,8 +1,6 @@ import Backbone from 'backbone'; import I18n from 'i18n-js'; -import {app, DestroyMenuItem} from 'pageflow/editor'; - -import {ExtractFragmentDialogView} from '../views/ExtractFragmentDialogView'; +import {DestroyMenuItem} from 'pageflow/editor'; export const CopyPermalinkMenuItem = Backbone.Model.extend({ initialize(attributes, {entry, chapter}) { @@ -18,40 +16,6 @@ export const CopyPermalinkMenuItem = Backbone.Model.extend({ } }); -export const ExtractFragmentMenuItem = Backbone.Model.extend({ - initialize(attributes, {chapter}) { - this.chapter = chapter; - this.update('extract_fragment', {disabled: false}); - }, - - selected() { - ExtractFragmentDialogView.show({ - title: this.chapter.configuration.get('title'), - onSubmit: title => this.extract(title) - }); - }, - - extract(title) { - this.update('extracting_fragment', {disabled: true}); - - return this.chapter.extractToFragmentLibrary({title}).then( - () => this.update('extracted_fragment', {disabled: true}), - ({status}) => { - this.update('extract_fragment', {disabled: false}); - app.trigger('error', { - message: t(status === 403 ? - 'extract_fragment_forbidden' : - 'extract_fragment_failed') - }); - } - ); - }, - - update(labelKey, {disabled}) { - this.set({label: t(labelKey), disabled}); - } -}); - export const ToggleExcursionMenuItem = Backbone.Model.extend({ initialize(attributes, {chapter}) { this.chapter = chapter; @@ -84,7 +48,3 @@ export const DestroyChapterMenuItem = DestroyMenuItem.extend({ ); } }); - -function t(key) { - return I18n.t(`pageflow_scrolled.editor.chapter_menu_items.${key}`); -} diff --git a/entry_types/scrolled/package/src/editor/views/EditChapterView.js b/entry_types/scrolled/package/src/editor/views/EditChapterView.js index 745e819ee8..4a2bc7c801 100644 --- a/entry_types/scrolled/package/src/editor/views/EditChapterView.js +++ b/entry_types/scrolled/package/src/editor/views/EditChapterView.js @@ -1,11 +1,9 @@ import {EditConfigurationView} from 'pageflow/editor'; import {CheckBoxInputView, TextInputView, TextAreaInputView} from 'pageflow/ui'; -import {features} from 'pageflow/frontend'; import { CopyPermalinkMenuItem, DestroyChapterMenuItem, - ExtractFragmentMenuItem, ToggleExcursionMenuItem } from '../models/chapterMenuItems'; @@ -16,9 +14,6 @@ export const EditChapterView = EditConfigurationView.extend({ return [ new ToggleExcursionMenuItem({}, {chapter: this.model}), new CopyPermalinkMenuItem({}, {entry: this.options.entry, chapter: this.model}), - ...(features.isEnabled('fragments') ? - [new ExtractFragmentMenuItem({}, {chapter: this.model})] : - []), new DestroyChapterMenuItem({separated: true}, {chapter: this.model}) ]; }, diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_extractions_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_extractions_controller_spec.rb deleted file mode 100644 index 0d8c73ee31..0000000000 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_extractions_controller_spec.rb +++ /dev/null @@ -1,298 +0,0 @@ -require 'spec_helper' -require 'pageflow/editor_controller_test_helper' -require 'pageflow/shared_contexts/fake_translations' - -module PageflowScrolled - RSpec.describe Editor::FragmentExtractionsController, type: :controller do - include Pageflow::EditorControllerTestHelper - - routes { PageflowScrolled::Engine.routes } - - describe '#create' do - it 'requires authentication' do - entry = create(:entry, type_name: 'scrolled') - chapter = create(:scrolled_chapter, revision: entry.draft) - - post_extraction(entry:, chapter:) - - expect(response.status).to eq(401) - end - - it 'creates fragment library for account of entry' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: entry.draft) - - sign_in_and_lock(entry, user) - - expect { post_extraction(entry:, chapter:) } - .to change { Pageflow::Entry.where(account:, fragment_library: 'shared').count }.by(1) - expect(response.status).to eq(201) - end - - it 'creates scrolled library entry' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: entry.draft) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(find_library(account).type_name).to eq('scrolled') - end - - describe 'library title' do - include_context 'fake translations' - - it 'includes account name in locale of entry' do - translation(:de, 'pageflow_scrolled.fragment_library.title', - 'Bausteine von %{account_name}') - translation(:en, 'pageflow_scrolled.fragment_library.title', - 'Shared fragments of %{account_name}') - user = create(:user) - account = create(:account, name: 'Acme', with_editor: user) - entry = create(:entry, - type_name: 'scrolled', - account:, - with_feature: 'fragments', - draft_attributes: {locale: 'de'}) - chapter = create(:scrolled_chapter, revision: entry.draft) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(find_library(account).title).to eq('Bausteine von Acme') - end - - it 'falls back to English for other locales' do - translation(:de, 'pageflow_scrolled.fragment_library.title', - 'Bausteine von %{account_name}') - translation(:en, 'pageflow_scrolled.fragment_library.title', - 'Shared fragments of %{account_name}') - user = create(:user) - account = create(:account, name: 'Acme', with_editor: user) - entry = create(:entry, - type_name: 'scrolled', - account:, - with_feature: 'fragments', - draft_attributes: {locale: 'fr'}) - chapter = create(:scrolled_chapter, revision: entry.draft) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(find_library(account).title).to eq('Shared fragments of Acme') - end - end - - it 'copies chapter with its sections into library' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, - revision: entry.draft, - configuration: {'title' => 'Intro', 'kind' => 'intro'}) - create(:section, chapter:, position: 0) - create(:section, chapter:, position: 1) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - fragment = Chapter.all_for_revision(find_library(account).draft).first - expect(fragment.configuration).to include('title' => 'Intro', 'kind' => 'intro') - expect(fragment.sections.count).to eq(2) - end - - it 'uses given title for fragment' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, - revision: entry.draft, - configuration: {'title' => 'Intro', 'kind' => 'intro'}) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:, title: 'Opening with video') - - fragment = Chapter.all_for_revision(find_library(account).draft).first - expect(fragment.configuration).to include('title' => 'Opening with video', - 'kind' => 'intro') - end - - it 'keeps chapter title if blank title is given' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, - revision: entry.draft, - configuration: {'title' => 'Intro'}) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:, title: ' ') - - fragment = Chapter.all_for_revision(find_library(account).draft).first - expect(fragment.configuration).to include('title' => 'Intro') - end - - it 'adds fragment to main storyline of library' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: entry.draft) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - fragment = Chapter.all_for_revision(find_library(account).draft).first - expect(fragment.storyline.role).to eq('main') - end - - it 'keeps sections of chapter in entry' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: entry.draft) - create(:section, chapter:) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(chapter.sections.count).to eq(1) - end - - it 'appends fragment to existing library of account' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, - revision: entry.draft, - configuration: {'title' => 'Outro'}) - library = create(:entry, type_name: 'scrolled', account:, fragment_library: 'shared') - create(:scrolled_chapter, - revision: library.draft, - position: 0, - configuration: {'title' => 'Intro'}) - - sign_in_and_lock(entry, user) - - expect { post_extraction(entry:, chapter:) } - .not_to(change { Pageflow::Entry.where(fragment_library: 'shared').count }) - expect(Chapter.all_for_revision(library.draft).map { |c| c.configuration['title'] }) - .to eq(%w[Intro Outro]) - end - - it 'does not add fragment to library of other account' do - user = create(:user) - account = create(:account, with_editor: user) - other_account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: entry.draft) - other_library = create(:entry, - type_name: 'scrolled', - account: other_account, - fragment_library: 'shared') - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(Chapter.all_for_revision(other_library.draft)).to be_empty - expect(find_library(account)).to be_present - end - - it 'does not let user without account editor role create library' do - user = create(:user) - account = create(:account, with_previewer: user) - entry = create(:entry, type_name: 'scrolled', account:, with_editor: user, - with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: entry.draft) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(response.status).to eq(403) - expect(find_library(account)).to be_nil - end - - it 'lets user with editor role on existing library add fragment' do - user = create(:user) - account = create(:account, with_previewer: user) - entry = create(:entry, type_name: 'scrolled', account:, with_editor: user, - with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: entry.draft) - library = create(:entry, - type_name: 'scrolled', - account:, - fragment_library: 'shared', - with_editor: user) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(response.status).to eq(201) - expect(Chapter.all_for_revision(library.draft).count).to eq(1) - end - - it 'does not let user who may not edit existing library add fragment' do - user = create(:user) - account = create(:account, with_previewer: user) - entry = create(:entry, type_name: 'scrolled', account:, with_editor: user, - with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: entry.draft) - library = create(:entry, type_name: 'scrolled', account:, fragment_library: 'shared') - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(response.status).to eq(403) - expect(Chapter.all_for_revision(library.draft)).to be_empty - end - - it 'responds with not found for chapter of other entry' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - other_entry = create(:entry, type_name: 'scrolled', account:, with_feature: 'fragments') - chapter = create(:scrolled_chapter, revision: other_entry.draft) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(response.status).to eq(404) - end - - it 'responds with not found if fragments feature is disabled' do - user = create(:user) - account = create(:account, with_editor: user) - entry = create(:entry, type_name: 'scrolled', account:) - chapter = create(:scrolled_chapter, revision: entry.draft) - - sign_in_and_lock(entry, user) - post_extraction(entry:, chapter:) - - expect(response.status).to eq(404) - expect(find_library(account)).to be_nil - end - end - - def find_library(account) - Pageflow::Entry.find_by(account:, fragment_library: 'shared') - end - - def post_extraction(entry:, chapter:, title: nil) - post(:create, - params: { - entry_type: 'scrolled', - entry_id: entry, - chapter_id: chapter, - title: - }.compact, format: 'json') - end - - def sign_in_and_lock(entry, user) - sign_in(user, scope: :user) - acquire_edit_lock(user, entry) - end - end -end diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller_spec.rb index d816e2047b..42dff6f83b 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_extractions_controller_spec.rb @@ -65,6 +65,40 @@ module PageflowScrolled expect(find_library(account)).to be_nil end + it 'lets user with editor role on existing library add fragment' do + user = create(:user) + account = create(:account, with_previewer: user) + entry = create(:entry, type_name: 'scrolled', account:, with_editor: user, + with_feature: 'fragments') + section = create(:section, revision: entry.draft) + library = create(:entry, + type_name: 'scrolled', + account:, + fragment_library: 'shared', + with_editor: user) + + sign_in_and_lock(entry, user) + post_extraction(entry:, sections: [section]) + + expect(response.status).to eq(201) + expect(Chapter.all_for_revision(library.draft).count).to eq(1) + end + + it 'does not let user who may not edit existing library add fragment' do + user = create(:user) + account = create(:account, with_previewer: user) + entry = create(:entry, type_name: 'scrolled', account:, with_editor: user, + with_feature: 'fragments') + section = create(:section, revision: entry.draft) + library = create(:entry, type_name: 'scrolled', account:, fragment_library: 'shared') + + sign_in_and_lock(entry, user) + post_extraction(entry:, sections: [section]) + + expect(response.status).to eq(403) + expect(Chapter.all_for_revision(library.draft)).to be_empty + end + it 'responds with not found for section of other entry' do user = create(:user) account = create(:account, with_editor: user) diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb index cdcbeba625..17a616c754 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb @@ -1,4 +1,5 @@ require 'spec_helper' +require 'pageflow/shared_contexts/fake_translations' module PageflowScrolled RSpec.describe FragmentLibrary do @@ -207,38 +208,6 @@ module PageflowScrolled end end - describe '#extract_fragment_from' do - it 'creates library entry on first extraction' do - account = create(:account, name: 'Acme') - entry = create(:entry, type_name: 'scrolled', account:) - chapter = create(:scrolled_chapter, revision: entry.draft) - library = FragmentLibrary.shared_for_account(account) - - library.extract_fragment_from(entry: Pageflow::DraftEntry.new(entry), chapter:) - - expect(library).to be_persisted - expect(library.entry.to_model).to have_attributes(account:, - type_name: 'scrolled', - fragment_library: 'shared') - end - - it 'copies chapter with its sections into library' do - account = create(:account) - entry = create(:entry, type_name: 'scrolled', account:) - chapter = create(:scrolled_chapter, - revision: entry.draft, - configuration: {'title' => 'Intro'}) - create(:section, chapter:) - library = FragmentLibrary.shared_for_account(account) - - library.extract_fragment_from(entry: Pageflow::DraftEntry.new(entry), chapter:) - - expect(library.content.chapters.map(&:configuration)) - .to match([include('title' => 'Intro')]) - expect(library.content.sections.size).to eq(1) - end - end - describe '#extract_sections_from' do it 'creates library entry on first extraction' do account = create(:account) @@ -250,6 +219,7 @@ module PageflowScrolled expect(library).to be_persisted expect(library.entry.to_model).to have_attributes(account:, + type_name: 'scrolled', fragment_library: 'shared') end @@ -286,6 +256,39 @@ module PageflowScrolled expect(library.content.chapters.first.configuration).to eq({}) end + + describe 'library title' do + include_context 'fake translations' + + before do + translation(:de, 'pageflow_scrolled.fragment_library.title', + 'Bausteine von %{account_name}') + translation(:en, 'pageflow_scrolled.fragment_library.title', + 'Shared fragments of %{account_name}') + end + + it 'includes account name in locale of entry' do + account = create(:account, name: 'Acme') + entry = create(:entry, type_name: 'scrolled', account:, draft_attributes: {locale: 'de'}) + section = create(:section, revision: entry.draft) + library = FragmentLibrary.shared_for_account(account) + + library.extract_sections_from(entry: Pageflow::DraftEntry.new(entry), sections: [section]) + + expect(library.entry.to_model.title).to eq('Bausteine von Acme') + end + + it 'falls back to English for other locales' do + account = create(:account, name: 'Acme') + entry = create(:entry, type_name: 'scrolled', account:, draft_attributes: {locale: 'fr'}) + section = create(:section, revision: entry.draft) + library = FragmentLibrary.shared_for_account(account) + + library.extract_sections_from(entry: Pageflow::DraftEntry.new(entry), sections: [section]) + + expect(library.entry.to_model.title).to eq('Shared fragments of Acme') + end + end end describe '#extract_content_elements_from' do From dba7c77c69a96f79f9bd7e9b235f20b4b439d32c Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 30 Oct 2026 11:27:41 +0100 Subject: [PATCH 11/15] Rename fragment insertions to align with content elements case Name the endpoint that inserts a sections fragment into a chapter section_fragment_insertions, next to section_fragment_extractions and the content_element_fragment_* endpoints. Rename FragmentLibrary#copy_fragment_to to copy_sections_to accordingly. --- ...troller.rb => section_fragment_insertions_controller.rb} | 4 ++-- .../app/models/pageflow_scrolled/fragment_library.rb | 2 +- .../create.json.jbuilder | 0 entry_types/scrolled/config/routes.rb | 2 +- .../scrolled/package/spec/editor/models/Chapter-spec.js | 4 ++-- entry_types/scrolled/package/src/editor/models/Chapter.js | 2 +- ...ec.rb => section_fragment_insertions_controller_spec.rb} | 2 +- .../spec/models/pageflow_scrolled/fragment_library_spec.rb | 6 +++--- 8 files changed, 11 insertions(+), 11 deletions(-) rename entry_types/scrolled/app/controllers/pageflow_scrolled/editor/{fragment_insertions_controller.rb => section_fragment_insertions_controller.rb} (85%) rename entry_types/scrolled/app/views/pageflow_scrolled/editor/{fragment_insertions => section_fragment_insertions}/create.json.jbuilder (100%) rename entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/{fragment_insertions_controller_spec.rb => section_fragment_insertions_controller_spec.rb} (98%) diff --git a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/fragment_insertions_controller.rb b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/section_fragment_insertions_controller.rb similarity index 85% rename from entry_types/scrolled/app/controllers/pageflow_scrolled/editor/fragment_insertions_controller.rb rename to entry_types/scrolled/app/controllers/pageflow_scrolled/editor/section_fragment_insertions_controller.rb index d20c88f0f4..c3d5f8b8af 100644 --- a/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/fragment_insertions_controller.rb +++ b/entry_types/scrolled/app/controllers/pageflow_scrolled/editor/section_fragment_insertions_controller.rb @@ -1,7 +1,7 @@ module PageflowScrolled module Editor # @api private - class FragmentInsertionsController < ActionController::Base + class SectionFragmentInsertionsController < ActionController::Base include Pageflow::EditorController before_action do @@ -13,7 +13,7 @@ def create @sections = authorized_scope(:read, FragmentLibrary) .find(fragment_params[:entry_id]) - .copy_fragment_to(fragment_perma_id: fragment_params[:chapter_perma_id], + .copy_sections_to(fragment_perma_id: fragment_params[:chapter_perma_id], entry: @entry, chapter:) diff --git a/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb b/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb index 5d037d0386..d95c3e21ef 100644 --- a/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb +++ b/entry_types/scrolled/app/models/pageflow_scrolled/fragment_library.rb @@ -39,7 +39,7 @@ def content @content ||= RevisionContent.new(entry.draft, include_hidden: true) end - def copy_fragment_to(fragment_perma_id:, entry:, chapter:) + def copy_sections_to(fragment_perma_id:, entry:, chapter:) SectionsCopy .new(source_entry: self.entry, destination_entry: entry) .perform(fragment_chapter(fragment_perma_id).sections, chapter:) diff --git a/entry_types/scrolled/app/views/pageflow_scrolled/editor/fragment_insertions/create.json.jbuilder b/entry_types/scrolled/app/views/pageflow_scrolled/editor/section_fragment_insertions/create.json.jbuilder similarity index 100% rename from entry_types/scrolled/app/views/pageflow_scrolled/editor/fragment_insertions/create.json.jbuilder rename to entry_types/scrolled/app/views/pageflow_scrolled/editor/section_fragment_insertions/create.json.jbuilder diff --git a/entry_types/scrolled/config/routes.rb b/entry_types/scrolled/config/routes.rb index 7772c149b0..ef73c0d040 100644 --- a/entry_types/scrolled/config/routes.rb +++ b/entry_types/scrolled/config/routes.rb @@ -30,7 +30,7 @@ put :order end - resources :fragment_insertions, only: [:create] + resources :section_fragment_insertions, only: [:create] resources :sections, only: [:create, :update, :destroy] do collection do 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 4d0df4758c..6f99504915 100644 --- a/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js +++ b/entry_types/scrolled/package/spec/editor/models/Chapter-spec.js @@ -290,7 +290,7 @@ describe('Chapter', () => { function respond(server, sections) { server.respond( - 'POST', '/editor/entries/1/scrolled/chapters/10/fragment_insertions', + 'POST', '/editor/entries/1/scrolled/chapters/10/section_fragment_insertions', [201, {'Content-Type': 'application/json'}, JSON.stringify(sections)] ); server.respond( @@ -306,7 +306,7 @@ describe('Chapter', () => { expect(requests[0].method).toBe('POST'); expect(requests[0].url) - .toBe('/editor/entries/1/scrolled/chapters/10/fragment_insertions'); + .toBe('/editor/entries/1/scrolled/chapters/10/section_fragment_insertions'); expect(requests[0].requestBody) .toContain('fragment%5Bchapter_perma_id%5D=700'); }); diff --git a/entry_types/scrolled/package/src/editor/models/Chapter.js b/entry_types/scrolled/package/src/editor/models/Chapter.js index 87692680db..8250009662 100644 --- a/entry_types/scrolled/package/src/editor/models/Chapter.js +++ b/entry_types/scrolled/package/src/editor/models/Chapter.js @@ -122,7 +122,7 @@ export const Chapter = Backbone.Model.extend({ insertFragment(fragment) { return $.ajax({ - url: `${this.url()}/fragment_insertions`, + url: `${this.url()}/section_fragment_insertions`, type: 'POST', dataType: 'json', data: { diff --git a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_insertions_controller_spec.rb b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_insertions_controller_spec.rb similarity index 98% rename from entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_insertions_controller_spec.rb rename to entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_insertions_controller_spec.rb index 41b179479c..2311054b16 100644 --- a/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/fragment_insertions_controller_spec.rb +++ b/entry_types/scrolled/spec/controllers/pageflow_scrolled/editor/section_fragment_insertions_controller_spec.rb @@ -2,7 +2,7 @@ require 'pageflow/editor_controller_test_helper' module PageflowScrolled - RSpec.describe Editor::FragmentInsertionsController, type: :controller do + RSpec.describe Editor::SectionFragmentInsertionsController, type: :controller do render_views include Pageflow::EditorControllerTestHelper diff --git a/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb b/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb index 17a616c754..cc572dc411 100644 --- a/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb +++ b/entry_types/scrolled/spec/models/pageflow_scrolled/fragment_library_spec.rb @@ -80,7 +80,7 @@ module PageflowScrolled end end - describe '#copy_fragment_to' do + describe '#copy_sections_to' do it 'copies sections of fragment chapter to end of chapter' do library_entry = create(:entry, type_name: 'scrolled', fragment_library: 'shared') fragment = create(:scrolled_chapter, revision: library_entry.draft) @@ -90,7 +90,7 @@ module PageflowScrolled create(:section, chapter:, position: 0) library = FragmentLibrary.new(Pageflow::DraftEntry.new(library_entry)) - sections = library.copy_fragment_to(fragment_perma_id: fragment.perma_id, + sections = library.copy_sections_to(fragment_perma_id: fragment.perma_id, entry: Pageflow::DraftEntry.new(entry), chapter:) @@ -106,7 +106,7 @@ module PageflowScrolled library = FragmentLibrary.new(Pageflow::DraftEntry.new(library_entry)) expect { - library.copy_fragment_to(fragment_perma_id: 404, + library.copy_sections_to(fragment_perma_id: 404, entry: Pageflow::DraftEntry.new(entry), chapter:) }.to raise_error(ActiveRecord::RecordNotFound) From bfcaba0ba9fba7ec7720f4e50c20daab605e2d55 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 30 Oct 2026 11:40:32 +0100 Subject: [PATCH 12/15] Align reorder chapters banner with selection banners Style the banner shown while reordering chapters like the section selection banner: smaller padding, button on its own line at the right and with a compact height. Drop the important padding override of the button. --- .../package/src/editor/views/StorylineItemView.js | 2 +- .../src/editor/views/StorylineItemView.module.css | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/entry_types/scrolled/package/src/editor/views/StorylineItemView.js b/entry_types/scrolled/package/src/editor/views/StorylineItemView.js index a7c39ac457..f01d55a95d 100644 --- a/entry_types/scrolled/package/src/editor/views/StorylineItemView.js +++ b/entry_types/scrolled/package/src/editor/views/StorylineItemView.js @@ -12,7 +12,7 @@ export const StorylineItemView = Marionette.Layout.extend({ className: styles.root, template: () => ` -
+

${I18n.t('pageflow_scrolled.editor.storyline_item.reorder_chapters')}

diff --git a/entry_types/scrolled/package/src/editor/views/StorylineItemView.module.css b/entry_types/scrolled/package/src/editor/views/StorylineItemView.module.css index 56c6a44269..945af29e18 100644 --- a/entry_types/scrolled/package/src/editor/views/StorylineItemView.module.css +++ b/entry_types/scrolled/package/src/editor/views/StorylineItemView.module.css @@ -12,17 +12,21 @@ } .expandChapters { + composes: banner from './banner.module.css'; margin-top: space(2); } +.expandChapters p { + margin: 0; +} + .expandChaptersButton { - composes: saveButton from './buttons.module.css'; - padding-top: 3px !important; - padding-bottom: 3px !important; + composes: saveButton compactButton from './buttons.module.css'; + align-self: flex-end; } .collapsed .expandChapters { - display: block !important; + display: flex !important; } .chapters { From 36b48541a9d403d389c8be2900254370f7759377 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 30 Oct 2026 15:03:04 +0100 Subject: [PATCH 13/15] Keep marked sections on entry Move the selection of marked sections from the outline view to ScrolledEntry#markedSections. Other parts of the editor like the controller that talks to the preview can thereby access and change marks. Navigating away from the outline ends marking. --- .../ScrolledEntry/markedSections-spec.js | 28 ++++++++++ .../spec/editor/views/ChapterItemView-spec.js | 16 +++--- .../editor/views/EntryOutlineView-spec.js | 22 ++++++++ .../spec/editor/views/SectionItemView-spec.js | 42 +++++++-------- .../src/editor/models/ScrolledEntry/index.js | 5 +- .../src/editor/views/ChapterItemView.js | 8 +-- .../src/editor/views/EntryOutlineView.js | 52 +++++++++---------- .../src/editor/views/SectionItemView.js | 16 +++--- .../src/editor/views/StorylineItemView.js | 2 +- 9 files changed, 122 insertions(+), 69 deletions(-) create mode 100644 entry_types/scrolled/package/spec/editor/models/ScrolledEntry/markedSections-spec.js diff --git a/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/markedSections-spec.js b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/markedSections-spec.js new file mode 100644 index 0000000000..61e7a47b63 --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/models/ScrolledEntry/markedSections-spec.js @@ -0,0 +1,28 @@ +import {useEditorGlobals} from 'support'; + +describe('ScrolledEntry#markedSections', () => { + const {createEntry} = useEditorGlobals(); + + it('is inactive by default', () => { + const entry = createEntry({sections: [{id: 10}]}); + + expect(entry.markedSections.isActive()).toBe(false); + }); + + it('can mark sections', () => { + const entry = createEntry({sections: [{id: 10}]}); + + entry.markedSections.toggle(entry.sections.get(10)); + + expect(entry.markedSections.includes(entry.sections.get(10))).toBe(true); + }); + + it('drops removed sections', () => { + const entry = createEntry({sections: [{id: 10}, {id: 11}]}); + entry.markedSections.add([entry.sections.get(10), entry.sections.get(11)]); + + entry.sections.remove(entry.sections.get(11)); + + expect(entry.markedSections.models).toEqual([entry.sections.get(10)]); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js b/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js index 2d9e13238f..0ca21dfff1 100644 --- a/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/ChapterItemView-spec.js @@ -134,31 +134,31 @@ describe('ChapterItemView', () => { describe('with section selection', () => { function renderChapterItem() { const entry = createEntry({chapters: [{id: 1}], sections: [{id: 10, chapterId: 1}]}); - const sectionSelection = new ListSelection(); - const view = new ChapterItemView({entry, model: entry.chapters.get(1), sectionSelection}); + const markedSections = new ListSelection(); + const view = new ChapterItemView({entry, model: entry.chapters.get(1), markedSections}); render(view); - return {entry, sectionSelection}; + return {entry, markedSections}; } afterEach(() => jest.restoreAllMocks()); it('disables sorting of sections while selection is active', () => { const disableSorting = jest.spyOn(SortableCollectionView.prototype, 'disableSorting'); - const {sectionSelection} = renderChapterItem(); + const {markedSections} = renderChapterItem(); - sectionSelection.start(); + markedSections.start(); expect(disableSorting).toHaveBeenCalled(); }); it('enables sorting of sections again when selection ends', () => { - const {sectionSelection} = renderChapterItem(); - sectionSelection.start(); + const {markedSections} = renderChapterItem(); + markedSections.start(); const enableSorting = jest.spyOn(SortableCollectionView.prototype, 'enableSorting'); - sectionSelection.stop(); + markedSections.stop(); expect(enableSorting).toHaveBeenCalled(); }); diff --git a/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js b/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js index ba77adea94..2683045cdc 100644 --- a/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EntryOutlineView-spec.js @@ -567,6 +567,28 @@ describe('EntryOutlineView', () => { .toHaveAttribute('aria-pressed', 'false'); }); + it('shows sections marked before outline was rendered', () => { + const entry = createEntryWithSections(); + entry.markedSections.add(entry.sections.get(10)); + + render(new EntryOutlineView({entry})); + + expect(screen.getByText('1 section selected')).toBeInTheDocument(); + expect(screen.getAllByRole('button', {name: 'Mark section'})[0]) + .toHaveAttribute('aria-pressed', 'true'); + }); + + it('ends marking when outline is closed', () => { + const entry = createEntryWithSections(); + const view = new EntryOutlineView({entry}); + render(view); + entry.markedSections.add(entry.sections.get(10)); + + view.close(); + + expect(entry.markedSections.isActive()).toBe(false); + }); + it('drops removed sections from selection', async () => { const entry = createEntryWithSections(); const user = userEvent.setup(); diff --git a/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js b/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js index 1752f5f607..2b8212e74c 100644 --- a/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js @@ -233,10 +233,10 @@ describe('SectionItemView', () => { }); describe('marking', () => { - function renderSectionItem({sectionSelection} = {}) { + function renderSectionItem({markedSections} = {}) { const entry = createEntry({sections: [{id: 1, permaId: 100}]}); const section = entry.sections.get(1); - const view = new SectionItemView({entry, model: section, sectionSelection}); + const view = new SectionItemView({entry, model: section, markedSections}); return {entry, section, ...render(view)}; } @@ -248,63 +248,63 @@ describe('SectionItemView', () => { }); it('renders unpressed mark button', () => { - const {getByRole} = renderSectionItem({sectionSelection: new ListSelection()}); + const {getByRole} = renderSectionItem({markedSections: new ListSelection()}); expect(getByRole('button', {name: 'Mark section'})) .toHaveAttribute('aria-pressed', 'false'); }); it('marks section when mark button is clicked', () => { - const sectionSelection = new ListSelection(); - const {section, getByRole} = renderSectionItem({sectionSelection}); + const markedSections = new ListSelection(); + const {section, getByRole} = renderSectionItem({markedSections}); fireEvent.click(getByRole('button', {name: 'Mark section'})); - expect(sectionSelection.includes(section)).toBe(true); + expect(markedSections.includes(section)).toBe(true); expect(getByRole('button', {name: 'Mark section'})) .toHaveAttribute('aria-pressed', 'true'); }); it('unmarks marked section when mark button is clicked', () => { - const sectionSelection = new ListSelection(); - const {section, getByRole} = renderSectionItem({sectionSelection}); - sectionSelection.add(section); + const markedSections = new ListSelection(); + const {section, getByRole} = renderSectionItem({markedSections}); + markedSections.add(section); fireEvent.click(getByRole('button', {name: 'Mark section'})); - expect(sectionSelection.includes(section)).toBe(false); + expect(markedSections.includes(section)).toBe(false); }); it('marks section on modifier click', () => { - const sectionSelection = new ListSelection(); - const {section, getByTitle} = renderSectionItem({sectionSelection}); + const markedSections = new ListSelection(); + const {section, getByTitle} = renderSectionItem({markedSections}); fireEvent.click(getByTitle('Drag to move'), {ctrlKey: true}); - expect(sectionSelection.includes(section)).toBe(true); + expect(markedSections.includes(section)).toBe(true); }); it('marks section on click with meta key', () => { - const sectionSelection = new ListSelection(); - const {section, getByTitle} = renderSectionItem({sectionSelection}); + const markedSections = new ListSelection(); + const {section, getByTitle} = renderSectionItem({markedSections}); fireEvent.click(getByTitle('Drag to move'), {metaKey: true}); - expect(sectionSelection.includes(section)).toBe(true); + expect(markedSections.includes(section)).toBe(true); }); it('does not mark section on plain click', () => { - const sectionSelection = new ListSelection(); - const {section, getByTitle} = renderSectionItem({sectionSelection}); + const markedSections = new ListSelection(); + const {section, getByTitle} = renderSectionItem({markedSections}); fireEvent.click(getByTitle('Drag to move')); - expect(sectionSelection.includes(section)).toBe(false); + expect(markedSections.includes(section)).toBe(false); }); it('scrolls to section when mark button is clicked', () => { - const sectionSelection = new ListSelection(); - const {entry, section, getByRole} = renderSectionItem({sectionSelection}); + const markedSections = new ListSelection(); + const {entry, section, getByRole} = renderSectionItem({markedSections}); const listener = jest.fn(); entry.on('scrollToSection', listener); 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 3d4536ad49..9cf14c9b37 100644 --- a/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js +++ b/entry_types/scrolled/package/src/editor/models/ScrolledEntry/index.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import {Entry, editor} from 'pageflow/editor'; +import {Entry, ListSelection, editor} from 'pageflow/editor'; import I18n from 'i18n-js'; import {features} from 'pageflow/frontend'; import {createReviewSession} from 'pageflow/review'; @@ -92,6 +92,9 @@ export const ScrolledEntry = Entry.extend({ this.cutoff = new Cutoff({entry: this}); + this.markedSections = new ListSelection(); + this.listenTo(this.sections, 'remove', section => this.markedSections.remove(section)); + editor.failures.watch(this.contentElements); editor.failures.watch(this.sections); editor.failures.watch(this.chapters); diff --git a/entry_types/scrolled/package/src/editor/views/ChapterItemView.js b/entry_types/scrolled/package/src/editor/views/ChapterItemView.js index 8cea267875..fbd0edd946 100644 --- a/entry_types/scrolled/package/src/editor/views/ChapterItemView.js +++ b/entry_types/scrolled/package/src/editor/views/ChapterItemView.js @@ -76,14 +76,14 @@ export const ChapterItemView = Marionette.Layout.extend({ itemViewConstructor: SectionItemView, itemViewOptions: { entry: this.options.entry, - sectionSelection: this.options.sectionSelection + markedSections: this.options.markedSections }, connectWith: cssModulesUtils.selector(styles, 'sections'), forceDraggableFallback: browser.agent.matchesDesktopSafari() })); - if (this.options.sectionSelection) { - this.listenTo(this.options.sectionSelection, + if (this.options.markedSections) { + this.listenTo(this.options.markedSections, 'add remove reset change:selecting', this.updateSorting); this.updateSorting(); @@ -93,7 +93,7 @@ export const ChapterItemView = Marionette.Layout.extend({ }, updateSorting() { - if (this.options.sectionSelection.isActive()) { + if (this.options.markedSections.isActive()) { this.sectionsView.disableSorting(); } else { diff --git a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js index c07947ab51..996bab3466 100644 --- a/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js +++ b/entry_types/scrolled/package/src/editor/views/EntryOutlineView.js @@ -2,7 +2,7 @@ import Marionette from 'backbone.marionette'; import Backbone from 'backbone'; import I18n from 'i18n-js'; import {cssModulesUtils} from 'pageflow/ui'; -import {DropDownButtonView, ListSelection} from 'pageflow/editor'; +import {DropDownButtonView} from 'pageflow/editor'; import {features} from 'pageflow/frontend'; import {StorylinesTabsView} from './StorylinesTabsView'; @@ -47,18 +47,18 @@ export const EntryOutlineView = Marionette.Layout.extend({ events: cssModulesUtils.events(styles, { 'click selectionBarDismiss': function() { - this.sectionSelection.stop(); + this.markedSections.stop(); }, 'click selectionBarAction': function() { const menuItem = new ExtractSectionsFragmentMenuItem({}, { entry: this.options.entry, - sections: this.sectionSelection.models.slice() + sections: this.markedSections.models.slice() }); ExtractFragmentDialogView.show({ onSubmit: title => { - this.sectionSelection.stop(); + this.markedSections.stop(); menuItem.extract(title); } }); @@ -67,7 +67,7 @@ export const EntryOutlineView = Marionette.Layout.extend({ initialize() { if (features.isEnabled('fragments')) { - this.sectionSelection = new ListSelection(); + this.markedSections = this.options.entry.markedSections; } }, @@ -87,9 +87,9 @@ export const EntryOutlineView = Marionette.Layout.extend({ dropDownMenuItems.add(this.reorderChaptersMenutItem); - if (this.sectionSelection) { + if (this.markedSections) { dropDownMenuItems.add(new SelectSectionsMenuItem({}, { - sectionSelection: this.sectionSelection + markedSections: this.markedSections })); this.setupSelectionBar(); @@ -113,52 +113,52 @@ export const EntryOutlineView = Marionette.Layout.extend({ itemViewOptions: { entry: this.options.entry, viewModel, - sectionSelection: this.sectionSelection + markedSections: this.markedSections } }); this.listenTo(storylinesTabsView, 'change', () => { viewModel.set('collapsed', false); - this.sectionSelection?.stop(); + this.markedSections?.stop(); }); this.appendSubview(storylinesTabsView, {to: this.ui.tabs}); this.ui.tabs.find('.tabs_view-scroller').first().append(this.ui.toolbar); - if (this.sectionSelection) { + if (this.markedSections) { this.ui.tabs.find('.tabs_view-container').first().before(this.ui.selectionBar); } }, + onClose() { + this.markedSections?.stop(); + }, + setupSelectionBar() { - this.listenTo(this.sectionSelection, + this.listenTo(this.markedSections, 'add remove reset change:selecting', this.updateSelectionBar); - this.listenTo(this.options.entry.sections, 'remove', section => - this.sectionSelection.remove(section) - ); - this.updateSelectionBar(); }, makeSelectionAndReorderingExclusive(viewModel) { this.listenTo(viewModel, 'change:collapsed', (model, collapsed) => { if (collapsed) { - this.sectionSelection.stop(); + this.markedSections.stop(); } }); - this.listenTo(this.sectionSelection, 'change:selecting add', () => { - if (this.sectionSelection.isActive()) { + this.listenTo(this.markedSections, 'change:selecting add', () => { + if (this.markedSections.isActive()) { viewModel.set('collapsed', false); } }); }, updateSelectionBar() { - const selection = this.sectionSelection; + const selection = this.markedSections; this.ui.selectionBar.prop('hidden', !selection.isActive()); this.ui.selectionBarText.text( @@ -171,25 +171,25 @@ export const EntryOutlineView = Marionette.Layout.extend({ }); const SelectSectionsMenuItem = Backbone.Model.extend({ - initialize(attributes, {sectionSelection}) { - this.sectionSelection = sectionSelection; + initialize(attributes, {markedSections}) { + this.markedSections = markedSections; - this.listenTo(sectionSelection, 'add remove reset change:selecting', this.updateLabel); + this.listenTo(markedSections, 'add remove reset change:selecting', this.updateLabel); this.updateLabel(); }, selected() { - if (this.sectionSelection.isActive()) { - this.sectionSelection.stop(); + if (this.markedSections.isActive()) { + this.markedSections.stop(); } else { - this.sectionSelection.start(); + this.markedSections.start(); } }, updateLabel() { this.set('label', I18n.t( - this.sectionSelection.isActive() ? + this.markedSections.isActive() ? 'pageflow_scrolled.editor.entry_outline.end_selection' : 'pageflow_scrolled.editor.entry_outline.select_sections' )); diff --git a/entry_types/scrolled/package/src/editor/views/SectionItemView.js b/entry_types/scrolled/package/src/editor/views/SectionItemView.js index a461d4f38f..b28157a639 100644 --- a/entry_types/scrolled/package/src/editor/views/SectionItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SectionItemView.js @@ -55,8 +55,8 @@ export const SectionItemView = Marionette.ItemView.extend({ events: cssModulesUtils.events(styles, { 'click clickMask': function(event) { - if (this.options.sectionSelection && (event.ctrlKey || event.metaKey)) { - this.options.sectionSelection.toggle(this.model); + if (this.options.markedSections && (event.ctrlKey || event.metaKey)) { + this.options.markedSections.toggle(this.model); } this.options.entry.trigger('selectSection', this.model); @@ -99,8 +99,8 @@ export const SectionItemView = Marionette.ItemView.extend({ this.updateHidden(); }); - if (this.options.sectionSelection) { - this.listenTo(this.options.sectionSelection, + if (this.options.markedSections) { + this.listenTo(this.options.markedSections, 'add remove reset change:selecting', this.updateMarked); } @@ -136,7 +136,7 @@ export const SectionItemView = Marionette.ItemView.extend({ openOnClick: true }), {to: this.ui.dropDownButton}); - if (this.options.sectionSelection) { + if (this.options.markedSections) { this.appendMarkButton(); } }, @@ -144,7 +144,7 @@ export const SectionItemView = Marionette.ItemView.extend({ appendMarkButton() { const markButton = new MarkButtonView({ model: this.model, - selection: this.options.sectionSelection, + selection: this.options.markedSections, label: I18n.t('pageflow_scrolled.editor.section_item.mark') }); @@ -181,8 +181,8 @@ export const SectionItemView = Marionette.ItemView.extend({ }, updateMarked() { - this.$el.toggleClass(styles.marking, !!this.options.sectionSelection?.isActive()); - this.$el.toggleClass(styles.marked, !!this.options.sectionSelection?.includes(this.model)); + this.$el.toggleClass(styles.marking, !!this.options.markedSections?.isActive()); + this.$el.toggleClass(styles.marked, !!this.options.markedSections?.includes(this.model)); }, updateHidden() { diff --git a/entry_types/scrolled/package/src/editor/views/StorylineItemView.js b/entry_types/scrolled/package/src/editor/views/StorylineItemView.js index f01d55a95d..b1ca087bfa 100644 --- a/entry_types/scrolled/package/src/editor/views/StorylineItemView.js +++ b/entry_types/scrolled/package/src/editor/views/StorylineItemView.js @@ -62,7 +62,7 @@ export const StorylineItemView = Marionette.Layout.extend({ itemViewConstructor: ChapterItemView, itemViewOptions: { entry: this.options.entry, - sectionSelection: this.options.sectionSelection + markedSections: this.options.markedSections }, blankSlateViewConstructor: this.model.isMain() ? null : ExcursionBlankSlateView }); From 015f9e8da166d198e230fff3655fe113afa7ef7f Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 30 Oct 2026 15:03:04 +0100 Subject: [PATCH 14/15] Select multiple sections in preview When the fragments feature is enabled, clicking sections with the Ctrl or Cmd key held extends the selection to a multi selection of type sectionSettings. Reducing it to a single section reverts to selecting the settings of that section. Multi selections of type section, which the outline will post, are carried over. Sections in either kind of multi selection are rendered with the section frame. --- .../features/sectionMultiSelection-spec.js | 144 ++++++++++++++++++ .../spec/support/pageObjects/inlineEditing.js | 8 +- .../inlineEditing/SectionDecorator.js | 41 +++-- .../frontend/inlineEditing/multiSelection.js | 32 +++- 4 files changed, 209 insertions(+), 16 deletions(-) create mode 100644 entry_types/scrolled/package/spec/frontend/inlineEditing/features/sectionMultiSelection-spec.js diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/features/sectionMultiSelection-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/sectionMultiSelection-spec.js new file mode 100644 index 0000000000..5e56f851d2 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/sectionMultiSelection-spec.js @@ -0,0 +1,144 @@ +import {useInlineEditingPageObjects, renderEntry} from 'support/pageObjects/inlineEditing'; +import {useFakeFeatures} from 'pageflow/testHelpers'; +import '@testing-library/jest-dom/extend-expect' + +describe('inline editing section multi selection', () => { + useInlineEditingPageObjects(); + useFakeFeatures('frontend', ['fragments']); + + function renderEntryWithThreeSections() { + return renderEntry({ + seed: { + sections: [ + {id: 1, permaId: 10}, + {id: 2, permaId: 20}, + {id: 3, permaId: 30} + ] + } + }); + } + + function lastSelectedPayload() { + return window.parent.postMessage.mock.calls + .map(([message]) => message) + .filter(message => message.type === 'SELECTED') + .pop() + .payload; + } + + it('selects multiple sections when section is clicked with modifier', () => { + const {getSectionByPermaId} = renderEntryWithThreeSections(); + + getSectionByPermaId(10).select(); + getSectionByPermaId(20).select({ctrlKey: true}); + + expect(lastSelectedPayload()).toEqual({ + type: 'multiSelection', + itemType: 'sectionSettings', + items: [{id: 1}, {id: 2}] + }); + }); + + it('marks multi selected sections as selected', () => { + const {getSectionByPermaId} = renderEntryWithThreeSections(); + + getSectionByPermaId(10).select(); + getSectionByPermaId(20).select({ctrlKey: true}); + + expect(getSectionByPermaId(10).isSelected()).toBe(true); + expect(getSectionByPermaId(20).isSelected()).toBe(true); + expect(getSectionByPermaId(30).isSelected()).toBe(false); + }); + + it('reverts to selection of section settings when reduced to one section', () => { + const {getSectionByPermaId} = renderEntryWithThreeSections(); + + getSectionByPermaId(10).select(); + getSectionByPermaId(20).select({ctrlKey: true}); + getSectionByPermaId(20).select({ctrlKey: true}); + + expect(lastSelectedPayload()).toEqual({type: 'sectionSettings', id: 1}); + }); + + it('carries over section selected by editor', () => { + const {getSectionByPermaId, simulateSelectMessage} = renderEntryWithThreeSections(); + + simulateSelectMessage({type: 'section', id: 1}); + getSectionByPermaId(20).select({ctrlKey: true}); + + expect(lastSelectedPayload()).toEqual({ + type: 'multiSelection', + itemType: 'sectionSettings', + items: [{id: 1}, {id: 2}] + }); + }); + + it('carries over multiple sections selected by editor', () => { + const {getSectionByPermaId, simulateSelectMessage} = renderEntryWithThreeSections(); + + simulateSelectMessage({ + type: 'multiSelection', + itemType: 'section', + items: [{id: 1}, {id: 2}] + }); + getSectionByPermaId(30).select({ctrlKey: true}); + + expect(lastSelectedPayload()).toEqual({ + type: 'multiSelection', + itemType: 'sectionSettings', + items: [{id: 1}, {id: 2}, {id: 3}] + }); + }); + + it('marks sections of multi selection by editor as selected', () => { + const {getSectionByPermaId, simulateSelectMessage} = renderEntryWithThreeSections(); + + simulateSelectMessage({ + type: 'multiSelection', + itemType: 'section', + items: [{id: 1}] + }); + + expect(getSectionByPermaId(10).isSelected()).toBe(true); + expect(getSectionByPermaId(20).isSelected()).toBe(false); + }); + + it('ends multi selection when section is clicked without modifier', () => { + const {getSectionByPermaId} = renderEntryWithThreeSections(); + + getSectionByPermaId(10).select(); + getSectionByPermaId(20).select({ctrlKey: true}); + getSectionByPermaId(30).select(); + + expect(lastSelectedPayload()).toEqual({type: 'sectionSettings', id: 3}); + }); + + it('resets selection when only section of multi selection by editor is clicked with modifier', () => { + const {getSectionByPermaId, simulateSelectMessage} = renderEntryWithThreeSections(); + + simulateSelectMessage({ + type: 'multiSelection', + itemType: 'section', + items: [{id: 1}] + }); + getSectionByPermaId(10).select({ctrlKey: true}); + + expect(lastSelectedPayload()).toEqual({}); + }); +}); + +describe('inline editing section multi selection without fragments feature', () => { + useInlineEditingPageObjects(); + + it('ignores sections clicked with modifier', () => { + const {getSectionByPermaId} = renderEntry({ + seed: {sections: [{id: 1, permaId: 10}, {id: 2, permaId: 20}]} + }); + + getSectionByPermaId(10).select(); + getSectionByPermaId(20).select({ctrlKey: true}); + + expect(getSectionByPermaId(10).isSelected()).toBe(true); + expect(getSectionByPermaId(20).isSelected()).toBe(false); + }); +}); diff --git a/entry_types/scrolled/package/spec/support/pageObjects/inlineEditing.js b/entry_types/scrolled/package/spec/support/pageObjects/inlineEditing.js index bbf6f0a59e..882fdd37b1 100644 --- a/entry_types/scrolled/package/spec/support/pageObjects/inlineEditing.js +++ b/entry_types/scrolled/package/spec/support/pageObjects/inlineEditing.js @@ -57,8 +57,12 @@ function createInlineEditingSectionPageObject(el) { return { ...baseCreateSectionPageObject(el), - select() { - fireEvent.mouseDown(selectionRect); + select({ctrlKey = false} = {}) { + fireEvent.mouseDown(selectionRect, {ctrlKey}); + }, + + isSelected() { + return selectionRect.getAttribute('aria-selected') === 'true'; }, clickAddContentElement() { diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js index fa8ef477f4..ac45cac5fb 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/SectionDecorator.js @@ -25,7 +25,13 @@ export function SectionDecorator({backdrop, section, contentElements, transition const {t} = useI18n({locale: 'ui'}); const commentingEnabled = features.isEnabled('commenting'); - const {isSelected: isSectionSelected, select, resetSelection} = useEditorSelection({ + const { + isSelected: isSectionSelected, + isMultiSelected: isSectionMultiSelected, + select, + extendSelection, + resetSelection + } = useEditorSelection({ id: section.id, type: 'sectionSettings' }); @@ -78,11 +84,17 @@ export function SectionDecorator({backdrop, section, contentElements, transition type: 'contentElement' }); - const {isSelected: isHighlighted} = useEditorSelection({ + const { + isSelected: isSectionHighlighted, + isMultiSelected: isSectionMarked + } = useEditorSelection({ id: section.id, type: 'section' }); + const isInMultiSelection = isSectionMarked || isSectionMultiSelected; + const isHighlighted = isSectionHighlighted || isInMultiSelection; + const transitionSelection = useEditorSelection({ id: section.id, type: 'sectionTransition' @@ -101,19 +113,22 @@ export function SectionDecorator({backdrop, section, contentElements, transition }); function selectIfOutsideContentItem(event) { - // Do not dissolve a multi selection when a modifier click lands - // next to the elements it contains. - if (hasSelectionModifier(event)) { + if (event.target.closest(`.${contentElementStyles.wrapper}`) || + event.target.closest(`.${backdropStyles.wrapper}`) || + event.target.closest(`.${widgetSelectionRectStyles.wrapper}`) || + event.target.closest(`.${paddingIndicatorStyles.indicator}`) || + event.target.closest(`.${styles.commentBadge}`) || + event.target.closest('#fullscreenRoot') || + event.target.closest('[data-floating-ui-portal]')) { return; } - if (!event.target.closest(`.${contentElementStyles.wrapper}`) && - !event.target.closest(`.${backdropStyles.wrapper}`) && - !event.target.closest(`.${widgetSelectionRectStyles.wrapper}`) && - !event.target.closest(`.${paddingIndicatorStyles.indicator}`) && - !event.target.closest(`.${styles.commentBadge}`) && - !event.target.closest('#fullscreenRoot') && - !event.target.closest('[data-floating-ui-portal]')) { + if (hasSelectionModifier(event)) { + if (features.isEnabled('fragments')) { + extendSelection(); + } + } + else { isSectionSelected ? resetSelection() : select(); } } @@ -121,7 +136,7 @@ export function SectionDecorator({backdrop, section, contentElements, transition return (
diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/multiSelection.js b/entry_types/scrolled/package/src/frontend/inlineEditing/multiSelection.js index 64a52320a7..ec2a658df5 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/multiSelection.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/multiSelection.js @@ -16,6 +16,8 @@ // selectionMode: true // } +const sectionSelectionTypes = ['section', 'sectionSettings']; + export function hasSelectionModifier(event) { return event.ctrlKey || event.metaKey; } @@ -43,6 +45,14 @@ export function watchSelectionModifier(window) { } export function extendMultiSelection(selection, {type, id}) { + // Do not dissolve a multi selection of content elements when a + // modifier click lands on the section next to the elements. + if (sectionSelectionTypes.includes(type) && isMultiSelectionOf(selection, 'contentElement')) { + return selection; + } + + selection = withSectionSelectionType(selection, type); + if (isMultiSelectionOf(selection, type)) { return isInMultiSelection(selection, {type, id}) ? withoutItem(selection, id) : @@ -79,6 +89,26 @@ export function isInMultiSelection(selection, {type, id}) { selection.items.some(item => item.id === id); } +// The outline selects sections with type `section`, the preview with +// type `sectionSettings`. Without this, extending a selection started +// in one place from the other would drop the sections selected so far. +function withSectionSelectionType(selection, type) { + if (!selection || !sectionSelectionTypes.includes(type)) { + return selection; + } + + if (selection.type === 'multiSelection' && + sectionSelectionTypes.includes(selection.itemType)) { + return {...selection, itemType: type}; + } + + if (sectionSelectionTypes.includes(selection.type)) { + return {...selection, type}; + } + + return selection; +} + function isMultiSelectionOf(selection, type) { return !!selection && selection.type === 'multiSelection' && @@ -96,5 +126,5 @@ function withoutItem(selection, id) { return {...selection, items}; } - return {type: selection.itemType, id: items[0].id}; + return items.length ? {type: selection.itemType, id: items[0].id} : null; } From f91caae6504422fad46b7261c45a29d06450e22f Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 30 Oct 2026 15:10:18 +0100 Subject: [PATCH 15/15] Sync marked sections with preview Post marked sections to the preview as multi selection of type section to render them with the section frame. Multi selections of section settings created in the preview mark the sections and show the outline. Selecting anything else in the preview ends marking without resetting the new selection of the preview. Remember the section last selected in the preview and include it when marking starts via a modifier click in the outline. Marking sections in the outline only scrolls the preview instead of selecting the section, which would replace the multi selection. --- .../PreviewMessageController-spec.js | 169 ++++++++++++++++++ .../spec/editor/views/SectionItemView-spec.js | 46 ++++- .../controllers/PreviewMessageController.js | 58 +++++- .../src/editor/views/SectionItemView.js | 18 +- 4 files changed, 284 insertions(+), 7 deletions(-) diff --git a/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js b/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js index 5169286234..0bd26d1721 100644 --- a/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js +++ b/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js @@ -704,6 +704,175 @@ describe('PreviewMessageController', () => { ); }); + describe('marked sections', () => { + function setup() { + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({ + sections: [{id: 1}, {id: 2}] + }) + }); + const iframeWindow = createSendingIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow, editor}); + + return {entry, editor, iframeWindow}; + } + + function nextSelectMessage(iframeWindow) { + return new Promise(resolve => { + function handler(event) { + if (event.data.type === 'SELECT') { + iframeWindow.removeEventListener('message', handler); + resolve(event.data.payload); + } + } + + iframeWindow.addEventListener('message', handler); + }); + } + + it('sends SELECT message with marked sections', async () => { + const {entry, iframeWindow} = setup(); + await postReadyMessageAndWaitForAcknowledgement(iframeWindow); + + const message = nextSelectMessage(iframeWindow); + entry.markedSections.add(entry.sections.get(1)); + + expect(await message).toEqual({ + type: 'multiSelection', + itemType: 'section', + items: [{id: 1}] + }); + }); + + it('sends SELECT message with empty selection when marking ends', async () => { + const {entry, iframeWindow} = setup(); + await postReadyMessageAndWaitForAcknowledgement(iframeWindow); + entry.markedSections.add(entry.sections.get(1)); + await tick(); + + const message = nextSelectMessage(iframeWindow); + entry.markedSections.stop(); + + expect(await message).toBeNull(); + }); + + it('marks sections of SELECTED multi selection of section settings', async () => { + const {entry, editor} = setup(); + + await new Promise(resolve => { + editor.on('navigate', resolve); + window.postMessage({ + type: 'SELECTED', + payload: { + type: 'multiSelection', + itemType: 'sectionSettings', + items: [{id: 1}, {id: 2}] + } + }, '*'); + }); + + expect(entry.markedSections.pluck('id')).toEqual([1, 2]); + }); + + it('navigates to outline on SELECTED multi selection of section settings', () => { + const {editor} = setup(); + + return expect(new Promise(resolve => { + editor.on('navigate', resolve); + window.postMessage({ + type: 'SELECTED', + payload: { + type: 'multiSelection', + itemType: 'sectionSettings', + items: [{id: 1}, {id: 2}] + } + }, '*'); + })).resolves.toBe('/'); + }); + + it('keeps marks on SELECTED multi selection of sections', async () => { + const {entry} = setup(); + entry.markedSections.add(entry.sections.get(1)); + + window.postMessage({ + type: 'SELECTED', + payload: {type: 'multiSelection', itemType: 'section', items: [{id: 1}]} + }, '*'); + await tick(); + + expect(entry.markedSections.pluck('id')).toEqual([1]); + }); + + it('keeps marks on SELECTED message for section', async () => { + const {entry} = setup(); + entry.markedSections.add(entry.sections.get(1)); + + window.postMessage({type: 'SELECTED', payload: {type: 'section', id: 2}}, '*'); + await tick(); + + expect(entry.markedSections.isActive()).toBe(true); + }); + + it('ends marking on SELECTED message for section settings', async () => { + const {entry} = setup(); + entry.markedSections.add(entry.sections.get(1)); + + window.postMessage({type: 'SELECTED', payload: {type: 'sectionSettings', id: 2}}, '*'); + await tick(); + + expect(entry.markedSections.isActive()).toBe(false); + }); + + it('does not reset selection of preview when marking ends because of it', async () => { + const {entry, iframeWindow} = setup(); + await postReadyMessageAndWaitForAcknowledgement(iframeWindow); + entry.markedSections.add(entry.sections.get(1)); + await tick(); + const payloads = []; + iframeWindow.addEventListener('message', event => { + if (event.data.type === 'SELECT') { + payloads.push(event.data.payload); + } + }); + + window.postMessage({type: 'SELECTED', payload: {type: 'sectionSettings', id: 2}}, '*'); + await tick(); + await tick(); + + expect(payloads).toEqual([]); + }); + + it('ends marking on SELECTED message for empty selection', async () => { + const {entry} = setup(); + entry.markedSections.start(); + + window.postMessage({type: 'SELECTED', payload: {}}, '*'); + await tick(); + + expect(entry.markedSections.isActive()).toBe(false); + }); + + it('remembers section selected in preview', async () => { + const {entry} = setup(); + + window.postMessage({type: 'SELECTED', payload: {type: 'sectionSettings', id: 2}}, '*'); + await tick(); + + expect(entry.get('selectedSectionId')).toBe(2); + }); + + it('forgets selected section when other item is selected in preview', async () => { + const {entry} = setup(); + + window.postMessage({type: 'SELECTED', payload: {type: 'section', id: 2}}, '*'); + window.postMessage({type: 'SELECTED', payload: {type: 'widget', id: 'some'}}, '*'); + await tick(); + + expect(entry.get('selectedSectionId')).toBeNull(); + }); + }); + it('navigates to edit section route on SELECTED message for section settings', () => { const editor = factories.editorApi(); const entry = factories.entry(ScrolledEntry, {}, { diff --git a/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js b/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js index 2b8212e74c..7ae56079ef 100644 --- a/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/SectionItemView-spec.js @@ -234,7 +234,7 @@ describe('SectionItemView', () => { describe('marking', () => { function renderSectionItem({markedSections} = {}) { - const entry = createEntry({sections: [{id: 1, permaId: 100}]}); + const entry = createEntry({sections: [{id: 1, permaId: 100}, {id: 2, permaId: 200}]}); const section = entry.sections.get(1); const view = new SectionItemView({entry, model: section, markedSections}); @@ -302,6 +302,50 @@ describe('SectionItemView', () => { expect(markedSections.includes(section)).toBe(false); }); + it('also marks section selected in preview on first modifier click', () => { + const markedSections = new ListSelection(); + const {entry, section, getByTitle} = renderSectionItem({markedSections}); + entry.set('selectedSectionId', 2); + + fireEvent.click(getByTitle('Drag to move'), {ctrlKey: true}); + + expect(markedSections.pluck('id')).toEqual([2, 1]); + expect(markedSections.includes(section)).toBe(true); + }); + + it('does not select section in preview on modifier click', () => { + const markedSections = new ListSelection(); + const {entry, getByTitle} = renderSectionItem({markedSections}); + const listener = jest.fn(); + entry.on('selectSection', listener); + + fireEvent.click(getByTitle('Drag to move'), {ctrlKey: true}); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('scrolls to section on modifier click', () => { + const markedSections = new ListSelection(); + const {entry, section, getByTitle} = renderSectionItem({markedSections}); + const listener = jest.fn(); + entry.on('scrollToSection', listener); + + fireEvent.click(getByTitle('Drag to move'), {ctrlKey: true}); + + expect(listener).toHaveBeenCalledWith(section); + }); + + it('does not select section in preview when mark button is clicked', () => { + const markedSections = new ListSelection(); + const {entry, getByRole} = renderSectionItem({markedSections}); + const listener = jest.fn(); + entry.on('selectSection', listener); + + fireEvent.click(getByRole('button', {name: 'Mark section'})); + + expect(listener).not.toHaveBeenCalled(); + }); + it('scrolls to section when mark button is clicked', () => { const markedSections = new ListSelection(); const {entry, section, getByRole} = renderSectionItem({markedSections}); diff --git a/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js b/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js index bbaec8a84f..48c9d09167 100644 --- a/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js +++ b/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js @@ -193,6 +193,8 @@ export const PreviewMessageController = Object.extend({ postMessage({type: 'END_SELECTION_MODE'}) ); + this.watchMarkedSections(postMessage); + this.listenTo(this.entry.commentDisplayFilter, 'change:resolution change:alwaysShowComments', filter => @@ -238,9 +240,16 @@ export const PreviewMessageController = Object.extend({ message.data.payload.highlightedThreadId : undefined, selectedCommentsSubject: - selectedCommentsSubjectFor(this.entry, message.data.payload) + selectedCommentsSubjectFor(this.entry, message.data.payload), + selectedSectionId: type === 'section' || type === 'sectionSettings' ? id : null }); + if (!isSelectionOfSections(message.data.payload) && + this.entry.markedSections?.isActive()) { + this.previewShowsMarkedSections = false; + this.entry.markedSections.stop(); + } + if (type === 'contentElementComments' || type === 'sectionComments') { if (!onCommentsView() && message.data.payload.source !== 'editor') { this.editor.navigate('/scrolled/comments?tab=selection', {trigger: true}) @@ -263,8 +272,15 @@ export const PreviewMessageController = Object.extend({ } else if (type === 'multiSelection') { const {itemType, items, selectionMode} = message.data.payload; - const payload = encodeURIComponent(JSON.stringify({itemType, items, selectionMode})); - this.editor.navigate(`/scrolled/multi_selection?payload=${payload}`, {trigger: true}) + + if (itemType === 'sectionSettings') { + this.markSections(items); + this.editor.navigate('/', {trigger: true}); + } + else if (itemType !== 'section') { + const payload = encodeURIComponent(JSON.stringify({itemType, items, selectionMode})); + this.editor.navigate(`/scrolled/multi_selection?payload=${payload}`, {trigger: true}) + } } else if (type === 'sectionSettings') { this.editor.navigate(`/scrolled/sections/${id}`, {trigger: true}) @@ -355,6 +371,37 @@ export const PreviewMessageController = Object.extend({ } }, + watchMarkedSections(postMessage) { + const markedSections = this.entry.markedSections; + this.previewShowsMarkedSections = markedSections.isActive(); + + this.listenTo(markedSections, 'add remove reset change:selecting', () => { + const active = markedSections.isActive(); + + if (active || this.previewShowsMarkedSections) { + postMessage({ + type: 'SELECT', + payload: active ? + { + type: 'multiSelection', + itemType: 'section', + items: markedSections.map(section => ({id: section.id})) + } : + null + }); + } + + this.previewShowsMarkedSections = active; + }); + }, + + markSections(items) { + this.entry.markedSections.reset( + items.map(({id}) => this.entry.sections.get(id)).filter(Boolean) + ); + this.entry.markedSections.start(); + }, + preserveScrollPoint(callback) { if (this.preservedScrollTarget) { const {sectionId, align} = this.preservedScrollTarget; @@ -410,3 +457,8 @@ function onCommentsView() { return fragment === 'scrolled/comments' || fragment.startsWith('scrolled/comments?'); } + +function isSelectionOfSections({type, itemType}) { + return type === 'section' || + (type === 'multiSelection' && (itemType === 'section' || itemType === 'sectionSettings')); +} diff --git a/entry_types/scrolled/package/src/editor/views/SectionItemView.js b/entry_types/scrolled/package/src/editor/views/SectionItemView.js index b28157a639..e0e5c9f991 100644 --- a/entry_types/scrolled/package/src/editor/views/SectionItemView.js +++ b/entry_types/scrolled/package/src/editor/views/SectionItemView.js @@ -56,10 +56,12 @@ export const SectionItemView = Marionette.ItemView.extend({ events: cssModulesUtils.events(styles, { 'click clickMask': function(event) { if (this.options.markedSections && (event.ctrlKey || event.metaKey)) { - this.options.markedSections.toggle(this.model); + this.startMarking(); + } + else { + this.options.entry.trigger('selectSection', this.model); } - this.options.entry.trigger('selectSection', this.model); this.options.entry.trigger('scrollToSection', this.model); }, @@ -150,11 +152,21 @@ export const SectionItemView = Marionette.ItemView.extend({ this.appendSubview(markButton, {to: this.ui.markButton}); this.listenTo(markButton, 'toggle', () => { - this.options.entry.trigger('selectSection', this.model); this.options.entry.trigger('scrollToSection', this.model); }); }, + startMarking() { + const {entry, markedSections} = this.options; + const selectedSection = entry.sections.get(entry.get('selectedSectionId')); + + if (selectedSection && selectedSection !== this.model) { + markedSections.add(selectedSection); + } + + markedSections.add(this.model); + }, + updateTransition() { this.ui.transition.text( I18n.t(this.model.getTransition(),