From 566e49702fcd9a5821acfdbca56aac76c3cd2015 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 1 Aug 2023 11:19:38 -0700 Subject: [PATCH 01/12] feat: new UI to copy/paste units in Studio (behind waffle flag) --- cms/djangoapps/contentstore/toggles.py | 16 ++++ .../xblock_storage_handlers/view_handlers.py | 4 + cms/static/js/views/course_outline.js | 20 +++++ cms/static/js/views/xblock_outline.js | 3 +- cms/static/sass/elements/_modules.scss | 19 +++++ cms/templates/js/course-outline.underscore | 83 +++++++++++++------ 6 files changed, 120 insertions(+), 25 deletions(-) diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index 7a02efc07872..9ae4b0271c6f 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -214,6 +214,22 @@ def individualize_anonymous_user_id(course_id): ) +# .. toggle_name: contentstore.enable_copy_paste_units +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Moves most unit-level actions into a submenu and adds new "Copy Unit" and "Paste +# Unit" actions which can be used to copy units within or among courses. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2023-08-01 +# .. toggle_target_removal_date: 2023-10-01 +# .. toggle_tickets: https://github.com/openedx/modular-learning/issues/11 https://github.com/openedx/modular-learning/issues/50 +ENABLE_COPY_PASTE_UNITS = WaffleFlag( + f'{CONTENTSTORE_NAMESPACE}.enable_copy_paste_units', + __name__, + CONTENTSTORE_LOG_PREFIX, +) + + # .. toggle_name: contentstore.enable_studio_content_api # .. toggle_implementation: WaffleFlag # .. toggle_default: False diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index e26242f81f16..3e652c899b51 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -38,6 +38,7 @@ from xblock.fields import Scope from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG +from cms.djangoapps.contentstore.toggles import ENABLE_COPY_PASTE_UNITS from cms.djangoapps.models.settings.course_grading import CourseGradingModel from common.djangoapps.edxmako.services import MakoService from common.djangoapps.static_replace import replace_static_urls @@ -1346,6 +1347,9 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements else: xblock_info["staff_only_message"] = False + # If the ENABLE_COPY_PASTE_UNITS feature flag is enabled, we show the newer menu that allows copying/pasting + xblock_info["enable_copy_paste_units"] = ENABLE_COPY_PASTE_UNITS.is_enabled() + xblock_info[ "has_partition_group_components" ] = has_children_visible_to_specific_partition_groups(xblock) diff --git a/cms/static/js/views/course_outline.js b/cms/static/js/views/course_outline.js index 72647d3edb5b..59401e0417f9 100644 --- a/cms/static/js/views/course_outline.js +++ b/cms/static/js/views/course_outline.js @@ -216,6 +216,23 @@ function( } }, + /** + * If the new "Actions" menu is enabled, most actions like Configure, + * Duplicate, Move, Delete, etc. are moved into this menu. For this + * event, we just toggle displaying the menu. + * @param {*} event + */ + showActionsMenu: function(event) { + const showActionsButton = event.currentTarget; + const subMenu = showActionsButton.parentElement.querySelector(".wrapper-nav-sub"); + // Code in 'base.js' normally handles toggling these dropdowns but since this one is + // not present yet during the domReady event, we have to handle displaying it ourselves. + subMenu.classList.toggle("is-shown"); + // if propagation is not stopped, the event will bubble up to the + // body element, which will close the dropdown. + event.stopPropagation(); + }, + addButtonActions: function(element) { XBlockOutlineView.prototype.addButtonActions.apply(this, arguments); element.find('.configure-button').click(function(event) { @@ -232,6 +249,9 @@ function( this.highlightsXBlock(); } }.bind(this)); + element.find('.action-actions-menu').click((event) => { + this.showActionsMenu(event); + }); }, makeContentDraggable: function(element) { diff --git a/cms/static/js/views/xblock_outline.js b/cms/static/js/views/xblock_outline.js index c3ab7a8b0c2b..cc50efc4a265 100644 --- a/cms/static/js/views/xblock_outline.js +++ b/cms/static/js/views/xblock_outline.js @@ -110,7 +110,8 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldE includesChildren: this.shouldRenderChildren(), hasExplicitStaffLock: this.model.get('has_explicit_staff_lock'), staffOnlyMessage: this.model.get('staff_only_message'), - course: course + course: course, + enableCopyPasteUnits: this.model.get("enable_copy_paste_units"), // ENABLE_COPY_PASTE_UNITS waffle flag }; }, diff --git a/cms/static/sass/elements/_modules.scss b/cms/static/sass/elements/_modules.scss index 1eb5be08ccef..ab787deb18db 100644 --- a/cms/static/sass/elements/_modules.scss +++ b/cms/static/sass/elements/_modules.scss @@ -902,3 +902,22 @@ $outline-indent-width: $baseline; } } } + +// The "actions menu" for subsections, sections, and units on the Studio course outline page +.outline .actions-list.nav-dd .wrapper-nav-sub { + + @include text-align(left); // Undo the 'text-align: right' inherited from the parent + + z-index: 10; // stay in front of other components on the page. + + .nav-item { + a { + // Match styling of ".wrapper-header nav .nav-item a" (dropdowns in Studio header) + color: $gray-d1; + + &:hover { + color: $uxpl-blue-hover-active; + } + } + } +} diff --git a/cms/templates/js/course-outline.underscore b/cms/templates/js/course-outline.underscore index 87b59786a086..643af1484414 100644 --- a/cms/templates/js/course-outline.underscore +++ b/cms/templates/js/course-outline.underscore @@ -143,7 +143,7 @@ if (is_proctored_exam) { <% } else { %>
<% } %> -
- <% } %> + <% } %> <% } %> <% } %> From d398c8f122d455edb8abc0685ec930983d77e796 Mon Sep 17 00:00:00 2001 From: Yusuf Musleh Date: Mon, 7 Aug 2023 18:04:59 +0300 Subject: [PATCH 04/12] feat: WIP show/hide Unit paste buttons --- cms/static/js/views/course_outline.js | 253 +++++++++++++++++++++++++- cms/static/js/views/xblock_outline.js | 4 +- 2 files changed, 255 insertions(+), 2 deletions(-) diff --git a/cms/static/js/views/course_outline.js b/cms/static/js/views/course_outline.js index b0aae79eb489..7d8e446667de 100644 --- a/cms/static/js/views/course_outline.js +++ b/cms/static/js/views/course_outline.js @@ -19,9 +19,16 @@ function( templateName: 'course-outline', + initialize: function() { + XBlockOutlineView.prototype.initialize.call(this); + this.clipboardBroadcastChannel = new BroadcastChannel("studio_clipboard_channel"); + }, + render: function() { var renderResult = XBlockOutlineView.prototype.render.call(this); this.makeContentDraggable(this.el); + // Show/hide the paste button + this.initializePasteButton(this.el); return renderResult; }, @@ -214,15 +221,19 @@ function( const status = data.content?.status; if (status === "ready") { // The Unit has been copied and is ready to use. + this.refreshPasteButton(data); // Update our UI + this.clipboardBroadcastChannel.postMessage(data); // And notify any other open tabs return data; } else if (status === "loading") { - // The clipboard is being loaded asynchonously. + // The clipboard is being loaded asynchronously. // Poll the endpoint until the copying process is complete: const deferred = $.Deferred(); const checkStatus = () => { $.getJSON(clipboardEndpoint, (pollData) => { const newStatus = pollData.content?.status; if (newStatus === "ready") { + this.refreshPasteButton(data); + this.clipboardBroadcastChannel.postMessage(pollData); deferred.resolve(pollData); } else if (newStatus === "loading") { setTimeout(checkStatus, 1_000); @@ -241,6 +252,242 @@ function( }); }, + initializePasteButton(element) { + if ($(element).hasClass('outline-subsection')) { + if (this.options.canEdit) { + // We should have the user's clipboard status. + const data = this.options.clipboardData; + this.refreshPasteButton(data); + // Refresh the status when something is copied on another tab: + this.clipboardBroadcastChannel.onmessage = (event) => { this.refreshPasteButton(event.data); }; + } else { + this.$(".paste-component").hide(); + } + } + }, + + /** + * Given the latest information about the user's clipboard, hide or show the Paste button as appropriate. + */ + refreshPasteButton(data) { + // 'data' is the same data returned by the "get clipboard status" API endpoint + // i.e. /api/content-staging/v1/clipboard/ + if (this.options.canEdit && data.content) { + if (data.content.status === "expired") { + // This has expired and can no longer be pasted. + this.$(".paste-component").hide(); + } else if (data.content.block_type_display === 'Unit') { + // This is suitable for pasting into a unit. + const detailsPopupEl = this.$(".clipboard-details-popup")[0]; + // Only Units should have the paste button initialized + if (detailsPopupEl !== undefined) { + const detailsPopupEl = this.$(".clipboard-details-popup")[0]; + detailsPopupEl.querySelector(".detail-block-name").innerText = data.content.display_name; + detailsPopupEl.querySelector(".detail-block-type").innerText = data.content.block_type_display; + detailsPopupEl.querySelector(".detail-course-name").innerText = data.source_context_title; + if (data.source_edit_url) { + detailsPopupEl.setAttribute("href", data.source_edit_url); + detailsPopupEl.classList.remove("no-edit-link"); + } else { + detailsPopupEl.setAttribute("href", "#"); + detailsPopupEl.classList.add("no-edit-link"); + } + this.$('.paste-component').show() + } + + } else { + this.$('.paste-component').hide() + } + + } else { + this.$('.paste-component').hide(); + } + }, + + findXBlockElement: function(target) { + return $(target).closest('.outline-subsection'); + }, + + createPlaceholderElement: function() { + return $('
  • ', {class: 'outline-item outline-unit has-warnings is-draggable'}); + }, + + getURLRoot: function() { + return this.model.urlRoot; + }, + + /** + * Refreshes the specified xblock's display. If the xblock is an inline child of a + * reorderable container then the element will be refreshed inline. If not, then the + * parent container will be refreshed instead. + * @param element An element representing the xblock to be refreshed. + * @param block_added Flag to indicate that new block has been just added. + */ + refreshXBlock: function(element, block_added, is_duplicate) { + var xblockElement = this.findXBlockElement(element), + parentElement = xblockElement.parent(), + rootLocator = this.model.id; + + if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) { + this.render({refresh: true, block_added: block_added}); + } else if (parentElement.hasClass('reorderable-container')) { + this.refreshChildXBlock(xblockElement, block_added, is_duplicate); + } else { + this.refreshXBlock(this.findXBlockElement(parentElement)); + } + }, + + /** + * Refresh an xblock element inline on the page, using the specified xblockInfo. + * Note that the element is removed and replaced with the newly rendered xblock. + * @param xblockElement The xblock element to be refreshed. + * @param block_added Specifies if a block has been added, rather than just needs + * refreshing. + * @returns {jQuery promise} A promise representing the complete operation. + */ + refreshChildXBlock: function(xblockElement, block_added, is_duplicate) { + var self = this, + xblockInfo, + TemporaryXBlockView, + temporaryView; + xblockInfo = new XBlockInfo({ + id: xblockElement.data('locator') + }); + // There is only one Backbone view created on the container page, which is + // for the container xblock itself. Any child xblocks rendered inside the + // container do not get a Backbone view. Thus, create a temporary view + // to render the content, and then replace the original element with the result. + TemporaryXBlockView = XBlockView.extend({ + updateHtml: function(element, html) { + // Replace the element with the new HTML content, rather than adding + // it as child elements. + this.$el = $(html).replaceAll(element); // xss-lint: disable=javascript-jquery-insertion + } + }); + temporaryView = new TemporaryXBlockView({ + model: xblockInfo, + view: self.xblockView.new_child_view, + el: xblockElement + }); + return temporaryView.render({ + success: function() { + self.onXBlockRefresh(temporaryView, block_added, is_duplicate); + temporaryView.unbind(); // Remove the temporary view + }, + initRuntimeData: this + }); + }, + + onNewXBlock: function(xblockElement, scrollOffset, is_duplicate, data) { + var useNewTextEditor = this.$('.xblock-header-primary').attr('use-new-editor-text'), + useNewVideoEditor = this.$('.xblock-header-primary').attr('use-new-editor-video'), + useVideoGalleryFlow = this.$('.xblock-header-primary').attr("use-video-gallery-flow"), + useNewProblemEditor = this.$('.xblock-header-primary').attr('use-new-editor-problem'); + + // find the block type in the locator if available + if(data.hasOwnProperty('locator')) { + var matchBlockTypeFromLocator = /\@(.*?)\+/; + var blockType = data.locator.match(matchBlockTypeFromLocator); + } + if((useNewTextEditor === 'True' && blockType.includes('html')) + || (useNewVideoEditor === 'True' && blockType.includes('video')) + || (useNewProblemEditor === 'True' && blockType.includes('problem')) + ){ + var destinationUrl; + if (useVideoGalleryFlow === "True" && blockType.includes("video")) { + destinationUrl = this.$('.xblock-header-primary').attr("authoring_MFE_base_url") + '/course-videos/' + encodeURI(data.locator); + } + else { + destinationUrl = this.$('.xblock-header-primary').attr("authoring_MFE_base_url") + '/' + blockType[1] + '/' + encodeURI(data.locator); + } + window.location.href = destinationUrl; + return; + } + // ViewUtils.setScrollOffset(xblockElement, scrollOffset); + xblockElement.data('locator', data.locator); + return this.refreshXBlock(xblockElement, true, is_duplicate); + }, + + /** The user has clicked on the "Paste Unit button" */ + pasteUnit(event) { + // event.preventDefault(); + // Get the ID of the container (usually a unit/vertical) that we're pasting into: + const parentElement = this.findXBlockElement(event.target); + const parentLocator = parentElement.data('locator'); + // Create a placeholder XBlock while we're pasting: + const $placeholderEl = $(this.createPlaceholderElement()); + const addComponentsPanel = $(event.target).closest('.paste-component').prev(); + + // const listPanel = addComponentsPanel.prev(); + const listPanel = $(event.target).closest('.subsection-content').find('.list-units'); + + const scrollOffset = ViewUtils.getScrollOffset(addComponentsPanel); + const placeholderElement = $placeholderEl.appendTo(listPanel); + + // Start showing a "Pasting" notification: + ViewUtils.runOperationShowingMessage(gettext('Pasting'), () => { + return $.postJSON(this.getURLRoot() + '/', { + parent_locator: parentLocator, + staged_content: "clipboard", + }).then((data) => { + this.onNewXBlock(placeholderElement, scrollOffset, false, data); + return data; + }).fail(() => { + // Remove the placeholder if the paste failed + placeholderElement.remove(); + }); + }).done((data) => { + const { + conflicting_files: conflictingFiles, + error_files: errorFiles, + new_files: newFiles, + } = data.static_file_notices; + + const notices = []; + if (errorFiles.length) { + notices.push((next) => new PromptView.Error({ + title: gettext("Some errors occurred"), + message: ( + gettext("The following required files could not be added to the course:") + + " " + errorFiles.join(", ") + ), + actions: {primary: {text: gettext("OK"), click: (x) => { x.hide(); next(); }}}, + })); + } + if (conflictingFiles.length) { + notices.push((next) => new PromptView.Warning({ + title: gettext("You may need to update a file(s) manually"), + message: ( + gettext( + "The following files already exist in this course but don't match the " + + "version used by the component you pasted:" + ) + " " + conflictingFiles.join(", ") + ), + actions: {primary: {text: gettext("OK"), click: (x) => { x.hide(); next(); }}}, + })); + } + if (newFiles.length) { + notices.push(() => new NotificationView.Confirmation({ + title: gettext("New files were added to this course's Files & Uploads"), + message: ( + gettext("The following required files were imported to this course:") + + " " + newFiles.join(", ") + ), + closeIcon: true, + })); + } + if (notices.length) { + // Show the notices, one at a time: + const showNext = () => { + const view = notices.shift()(showNext); + view.show(); + } + // Delay to avoid conflict with the "Pasting..." notification. + setTimeout(showNext, 1250); + } + }); + }, + highlightsXBlock: function() { var modal = CourseOutlineModalsFactory.getModal('highlights', this.model, { onSave: this.refresh.bind(this), @@ -292,6 +539,10 @@ function( event.preventDefault(); this.copyXBlock(); }); + element.find('.paste-component-button').click((event) => { + event.preventDefault(); + this.pasteUnit(event); + }); element.find('.action-actions-menu').click((event) => { this.showActionsMenu(event); }); diff --git a/cms/static/js/views/xblock_outline.js b/cms/static/js/views/xblock_outline.js index cc50efc4a265..3d7c4d132de7 100644 --- a/cms/static/js/views/xblock_outline.js +++ b/cms/static/js/views/xblock_outline.js @@ -23,7 +23,9 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldE // takes XBlockInfo as a model options: { - collapsedClass: 'is-collapsed' + collapsedClass: 'is-collapsed', + canEdit: true, // If not specified, assume user has permission to make changes + clipboardData: { content: null }, }, templateName: 'xblock-outline', From 6b923c7dfb4fc8a6f43aa18db969fd260e910899 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 8 Aug 2023 14:14:50 -0700 Subject: [PATCH 05/12] fix: new-ish JS syntax in this file was breaking r.js build step --- cms/static/js/views/course_outline.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/cms/static/js/views/course_outline.js b/cms/static/js/views/course_outline.js index 7d8e446667de..0a38a503d4ef 100644 --- a/cms/static/js/views/course_outline.js +++ b/cms/static/js/views/course_outline.js @@ -216,9 +216,11 @@ function( ViewUtils.runOperationShowingMessage(gettext('Copying'), () => { return $.postJSON( clipboardEndpoint, - { usage_key: this.model.get('id') }, + { usage_key: this.model.get('id') } ).then((data) => { - const status = data.content?.status; + // const status = data.content?.status; + const status = data.content && data.content.status; + // ^ platform's old require.js/esprima breaks on newer syntax in some JS files but not all. if (status === "ready") { // The Unit has been copied and is ready to use. this.refreshPasteButton(data); // Update our UI @@ -230,20 +232,21 @@ function( const deferred = $.Deferred(); const checkStatus = () => { $.getJSON(clipboardEndpoint, (pollData) => { - const newStatus = pollData.content?.status; + // const newStatus = pollData.content?.status; + const newStatus = pollData.content && pollData.content.status; if (newStatus === "ready") { this.refreshPasteButton(data); this.clipboardBroadcastChannel.postMessage(pollData); deferred.resolve(pollData); } else if (newStatus === "loading") { - setTimeout(checkStatus, 1_000); + setTimeout(checkStatus, 1000); } else { deferred.reject(); throw new Error(`Unexpected clipboard status "${newStatus}" in successful API response.`); } }) } - setTimeout(checkStatus, 1_000); + setTimeout(checkStatus, 1000); return deferred; } else { throw new Error(`Unexpected clipboard status "${status}" in successful API response.`); From c89d8f72b96d0c6d27cb359cd546d6b834e4e507 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 8 Aug 2023 14:15:04 -0700 Subject: [PATCH 06/12] refactor: allow any vertical as a unit --- cms/static/js/views/course_outline.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/static/js/views/course_outline.js b/cms/static/js/views/course_outline.js index 0a38a503d4ef..fe4b8a245c99 100644 --- a/cms/static/js/views/course_outline.js +++ b/cms/static/js/views/course_outline.js @@ -279,8 +279,8 @@ function( if (data.content.status === "expired") { // This has expired and can no longer be pasted. this.$(".paste-component").hide(); - } else if (data.content.block_type_display === 'Unit') { - // This is suitable for pasting into a unit. + } else if (data.content.block_type === 'vertical') { + // This is suitable for pasting as a unit. const detailsPopupEl = this.$(".clipboard-details-popup")[0]; // Only Units should have the paste button initialized if (detailsPopupEl !== undefined) { From a49f12c11730b44bbc80f50a8886a248756814ee Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 10 Aug 2023 16:16:32 -0700 Subject: [PATCH 07/12] feat: get paste working --- cms/static/js/views/course_outline.js | 150 ++++++-------------------- 1 file changed, 31 insertions(+), 119 deletions(-) diff --git a/cms/static/js/views/course_outline.js b/cms/static/js/views/course_outline.js index fe4b8a245c99..2e0ee84c7c01 100644 --- a/cms/static/js/views/course_outline.js +++ b/cms/static/js/views/course_outline.js @@ -8,10 +8,11 @@ * - changes cause a refresh of the entire section rather than just the view for the changed xblock * - adding units will automatically redirect to the unit page rather than showing them inline */ -define(['jquery', 'underscore', 'js/views/xblock_outline', 'common/js/components/utils/view_utils', 'js/views/utils/xblock_utils', +define(['jquery', 'underscore', 'js/views/xblock_outline', 'edx-ui-toolkit/js/utils/string-utils', + 'common/js/components/utils/view_utils', 'js/views/utils/xblock_utils', 'js/models/xblock_outline_info', 'js/views/modals/course_outline_modals', 'js/utils/drag_and_drop'], function( - $, _, XBlockOutlineView, ViewUtils, XBlockViewUtils, + $, _, XBlockOutlineView, StringUtils, ViewUtils, XBlockViewUtils, XBlockOutlineInfo, CourseOutlineModalsFactory, ContentDragger ) { var CourseOutlineView = XBlockOutlineView.extend({ @@ -307,137 +308,48 @@ function( } }, - findXBlockElement: function(target) { - return $(target).closest('.outline-subsection'); - }, - - createPlaceholderElement: function() { - return $('
  • ', {class: 'outline-item outline-unit has-warnings is-draggable'}); - }, - - getURLRoot: function() { - return this.model.urlRoot; - }, - - /** - * Refreshes the specified xblock's display. If the xblock is an inline child of a - * reorderable container then the element will be refreshed inline. If not, then the - * parent container will be refreshed instead. - * @param element An element representing the xblock to be refreshed. - * @param block_added Flag to indicate that new block has been just added. - */ - refreshXBlock: function(element, block_added, is_duplicate) { - var xblockElement = this.findXBlockElement(element), - parentElement = xblockElement.parent(), - rootLocator = this.model.id; - - if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) { - this.render({refresh: true, block_added: block_added}); - } else if (parentElement.hasClass('reorderable-container')) { - this.refreshChildXBlock(xblockElement, block_added, is_duplicate); - } else { - this.refreshXBlock(this.findXBlockElement(parentElement)); - } - }, - - /** - * Refresh an xblock element inline on the page, using the specified xblockInfo. - * Note that the element is removed and replaced with the newly rendered xblock. - * @param xblockElement The xblock element to be refreshed. - * @param block_added Specifies if a block has been added, rather than just needs - * refreshing. - * @returns {jQuery promise} A promise representing the complete operation. - */ - refreshChildXBlock: function(xblockElement, block_added, is_duplicate) { - var self = this, - xblockInfo, - TemporaryXBlockView, - temporaryView; - xblockInfo = new XBlockInfo({ - id: xblockElement.data('locator') - }); - // There is only one Backbone view created on the container page, which is - // for the container xblock itself. Any child xblocks rendered inside the - // container do not get a Backbone view. Thus, create a temporary view - // to render the content, and then replace the original element with the result. - TemporaryXBlockView = XBlockView.extend({ - updateHtml: function(element, html) { - // Replace the element with the new HTML content, rather than adding - // it as child elements. - this.$el = $(html).replaceAll(element); // xss-lint: disable=javascript-jquery-insertion - } - }); - temporaryView = new TemporaryXBlockView({ - model: xblockInfo, - view: self.xblockView.new_child_view, - el: xblockElement - }); - return temporaryView.render({ - success: function() { - self.onXBlockRefresh(temporaryView, block_added, is_duplicate); - temporaryView.unbind(); // Remove the temporary view - }, - initRuntimeData: this - }); - }, - - onNewXBlock: function(xblockElement, scrollOffset, is_duplicate, data) { - var useNewTextEditor = this.$('.xblock-header-primary').attr('use-new-editor-text'), - useNewVideoEditor = this.$('.xblock-header-primary').attr('use-new-editor-video'), - useVideoGalleryFlow = this.$('.xblock-header-primary').attr("use-video-gallery-flow"), - useNewProblemEditor = this.$('.xblock-header-primary').attr('use-new-editor-problem'); - - // find the block type in the locator if available - if(data.hasOwnProperty('locator')) { - var matchBlockTypeFromLocator = /\@(.*?)\+/; - var blockType = data.locator.match(matchBlockTypeFromLocator); - } - if((useNewTextEditor === 'True' && blockType.includes('html')) - || (useNewVideoEditor === 'True' && blockType.includes('video')) - || (useNewProblemEditor === 'True' && blockType.includes('problem')) - ){ - var destinationUrl; - if (useVideoGalleryFlow === "True" && blockType.includes("video")) { - destinationUrl = this.$('.xblock-header-primary').attr("authoring_MFE_base_url") + '/course-videos/' + encodeURI(data.locator); - } - else { - destinationUrl = this.$('.xblock-header-primary').attr("authoring_MFE_base_url") + '/' + blockType[1] + '/' + encodeURI(data.locator); - } - window.location.href = destinationUrl; - return; - } - // ViewUtils.setScrollOffset(xblockElement, scrollOffset); - xblockElement.data('locator', data.locator); - return this.refreshXBlock(xblockElement, true, is_duplicate); + createPlaceholderElementForPaste: function(category, componentDisplayName) { + const nameStr = StringUtils.interpolate(gettext("Copy of '{componentDisplayName}'"), { componentDisplayName }, true); + const el = document.createElement("li"); + el.classList.add("outline-item", "outline-" + category, "has-warnings", "is-draggable"); + el.innerHTML = ` +
    +

    + + ${nameStr} + +

    +
    + +
    +
    + `; + return $(el); }, /** The user has clicked on the "Paste Unit button" */ pasteUnit(event) { // event.preventDefault(); - // Get the ID of the container (usually a unit/vertical) that we're pasting into: - const parentElement = this.findXBlockElement(event.target); - const parentLocator = parentElement.data('locator'); + // Get the ID of the parent container (a subsection if we're pasting a unit/vertical) that we're pasting into + const $parentElement = $(event.target).closest('.outline-item'); + const parentLocator = $parentElement.data('locator'); + // Get the display name of what we're pasting: + const displayName = $(event.target).closest('.paste-component').find('.detail-block-name').text(); // Create a placeholder XBlock while we're pasting: - const $placeholderEl = $(this.createPlaceholderElement()); - const addComponentsPanel = $(event.target).closest('.paste-component').prev(); - - // const listPanel = addComponentsPanel.prev(); - const listPanel = $(event.target).closest('.subsection-content').find('.list-units'); - - const scrollOffset = ViewUtils.getScrollOffset(addComponentsPanel); - const placeholderElement = $placeholderEl.appendTo(listPanel); + const $placeholderEl = this.createPlaceholderElementForPaste('unit', displayName); + const $listPanel = $(event.target).closest('.outline-content').children('ol').first(); + $listPanel.append($placeholderEl); // Start showing a "Pasting" notification: ViewUtils.runOperationShowingMessage(gettext('Pasting'), () => { - return $.postJSON(this.getURLRoot() + '/', { + return $.postJSON(this.model.urlRoot + '/', { parent_locator: parentLocator, staged_content: "clipboard", }).then((data) => { - this.onNewXBlock(placeholderElement, scrollOffset, false, data); + this.refresh(); // Update this and replace the placeholder with the actual pasted unit. return data; }).fail(() => { - // Remove the placeholder if the paste failed - placeholderElement.remove(); + $placeholderEl.remove(); }); }).done((data) => { const { From 24edc395d1fc056997b39f1105c91754791bd295 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 10 Aug 2023 16:36:26 -0700 Subject: [PATCH 08/12] feat: show clipboard status when page loads or is refreshed --- cms/djangoapps/contentstore/views/component.py | 11 ++--------- .../xblock_storage_handlers/view_handlers.py | 8 ++++++++ cms/static/js/views/course_outline.js | 2 +- cms/static/js/views/xblock_outline.js | 1 - 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 05bd39a3556b..148b259898cc 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -28,11 +28,7 @@ from cms.djangoapps.contentstore.toggles import use_new_problem_editor from openedx.core.lib.xblock_utils import get_aside_from_xblock, is_xblock_aside from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration -try: - # Technically this is a django app plugin, so we should not error if it's not installed: - import openedx.core.djangoapps.content_staging.api as content_staging_api -except ImportError: - content_staging_api = None +from openedx.core.djangoapps.content_staging import api as content_staging_api from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order from ..toggles import use_new_unit_page @@ -198,10 +194,7 @@ def container_handler(request, usage_key_string): index += 1 # Get the status of the user's clipboard so they can paste components if they have something to paste - if content_staging_api: - user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request) - else: - user_clipboard = {"content": None} + user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request) return render_to_response('container.html', { 'language_code': request.LANGUAGE_CODE, 'context_course': course, # Needed only for display of menus at top of page. diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 3e652c899b51..5a25e1c93e5f 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -14,6 +14,7 @@ from uuid import uuid4 from attrs import asdict +from crum import get_current_request from django.conf import settings from django.contrib.auth.decorators import login_required from django.contrib.auth.models import (User) # lint-amnesty, pylint: disable=imported-auth-user @@ -50,6 +51,7 @@ from common.djangoapps.util.json_request import JsonResponse, expect_json from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService from openedx.core.djangoapps.bookmarks import api as bookmarks_api +from openedx.core.djangoapps.content_staging import api as content_staging_api from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE from openedx.core.lib.gating import api as gating_api @@ -1233,6 +1235,12 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements "hide_after_due": xblock.hide_after_due, } ) + # Include clipboard data so we can paste units into this sequential if a unit is in the clipboard + if not is_library_block: # Only in courses for now + request = get_current_request() + if request: + clipboard_data = content_staging_api.get_user_clipboard_json(request.user.id, request) + xblock_info["user_clipboard"] = clipboard_data elif xblock.category in ("chapter", "course"): if xblock.category == "chapter": xblock_info.update( diff --git a/cms/static/js/views/course_outline.js b/cms/static/js/views/course_outline.js index 2e0ee84c7c01..9c7cefc99891 100644 --- a/cms/static/js/views/course_outline.js +++ b/cms/static/js/views/course_outline.js @@ -260,7 +260,7 @@ function( if ($(element).hasClass('outline-subsection')) { if (this.options.canEdit) { // We should have the user's clipboard status. - const data = this.options.clipboardData; + const data = this.model.get("user_clipboard") || {"content": null}; this.refreshPasteButton(data); // Refresh the status when something is copied on another tab: this.clipboardBroadcastChannel.onmessage = (event) => { this.refreshPasteButton(event.data); }; diff --git a/cms/static/js/views/xblock_outline.js b/cms/static/js/views/xblock_outline.js index 3d7c4d132de7..5896d5146683 100644 --- a/cms/static/js/views/xblock_outline.js +++ b/cms/static/js/views/xblock_outline.js @@ -25,7 +25,6 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldE options: { collapsedClass: 'is-collapsed', canEdit: true, // If not specified, assume user has permission to make changes - clipboardData: { content: null }, }, templateName: 'xblock-outline', From 3f350fff38b17650b97d40022bd9183c944518e0 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 11 Aug 2023 11:05:22 -0700 Subject: [PATCH 09/12] fix: don't include user clipboard in xblock_info which is user-agnostic --- cms/djangoapps/contentstore/views/course.py | 6 ++- .../xblock_storage_handlers/view_handlers.py | 8 ---- cms/static/js/factories/outline.js | 5 ++- cms/static/js/views/course_outline.js | 20 ++++----- cms/static/js/views/pages/course_outline.js | 41 ++++++++++++++++++- cms/static/js/views/xblock_outline.js | 4 +- cms/templates/course_outline.html | 3 +- 7 files changed, 61 insertions(+), 26 deletions(-) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 96743daf5116..28de4cfde9bb 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -1,8 +1,6 @@ """ Views related to operations on course objects """ - - import copy import json import logging @@ -60,6 +58,7 @@ from common.djangoapps.util.string_utils import _has_non_ascii_characters from common.djangoapps.xblock_django.api import deprecated_xblocks from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.content_staging import api as content_staging_api from openedx.core.djangoapps.credit.tasks import update_credit_course_requirements from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -747,6 +746,8 @@ def course_index(request, course_key): advanced_dict = CourseMetadata.fetch(course_block) proctoring_errors = CourseMetadata.validate_proctoring_settings(course_block, advanced_dict, request.user) + user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request) + return render_to_response('course_outline.html', { 'language_code': request.LANGUAGE_CODE, 'context_course': course_block, @@ -754,6 +755,7 @@ def course_index(request, course_key): 'sections': sections, 'course_structure': course_structure, 'initial_state': course_outline_initial_state(locator_to_show, course_structure) if locator_to_show else None, # lint-amnesty, pylint: disable=line-too-long + 'initial_user_clipboard': user_clipboard, 'rerun_notification_id': current_action.id if current_action else None, 'course_release_date': course_release_date, 'settings_url': settings_url, diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 5a25e1c93e5f..3e652c899b51 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -14,7 +14,6 @@ from uuid import uuid4 from attrs import asdict -from crum import get_current_request from django.conf import settings from django.contrib.auth.decorators import login_required from django.contrib.auth.models import (User) # lint-amnesty, pylint: disable=imported-auth-user @@ -51,7 +50,6 @@ from common.djangoapps.util.json_request import JsonResponse, expect_json from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService from openedx.core.djangoapps.bookmarks import api as bookmarks_api -from openedx.core.djangoapps.content_staging import api as content_staging_api from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE from openedx.core.lib.gating import api as gating_api @@ -1235,12 +1233,6 @@ def create_xblock_info( # lint-amnesty, pylint: disable=too-many-statements "hide_after_due": xblock.hide_after_due, } ) - # Include clipboard data so we can paste units into this sequential if a unit is in the clipboard - if not is_library_block: # Only in courses for now - request = get_current_request() - if request: - clipboard_data = content_staging_api.get_user_clipboard_json(request.user.id, request) - xblock_info["user_clipboard"] = clipboard_data elif xblock.category in ("chapter", "course"): if xblock.category == "chapter": xblock_info.update( diff --git a/cms/static/js/factories/outline.js b/cms/static/js/factories/outline.js index 57e2fa17abf3..caf7cbab0c54 100644 --- a/cms/static/js/factories/outline.js +++ b/cms/static/js/factories/outline.js @@ -3,12 +3,13 @@ define([ ], function(CourseOutlinePage, XBlockOutlineInfo) { 'use strict'; - return function(XBlockOutlineInfoJson, initialStateJson) { + return function(XBlockOutlineInfoJson, initialStateJson, initialUserClipboardJson) { var courseXBlock = new XBlockOutlineInfo(XBlockOutlineInfoJson, {parse: true}), view = new CourseOutlinePage({ el: $('#content'), model: courseXBlock, - initialState: initialStateJson + initialState: initialStateJson, + initialUserClipboard: initialUserClipboardJson, }); view.render(); }; diff --git a/cms/static/js/views/course_outline.js b/cms/static/js/views/course_outline.js index 9c7cefc99891..ba02f475b7ed 100644 --- a/cms/static/js/views/course_outline.js +++ b/cms/static/js/views/course_outline.js @@ -22,7 +22,6 @@ function( initialize: function() { XBlockOutlineView.prototype.initialize.call(this); - this.clipboardBroadcastChannel = new BroadcastChannel("studio_clipboard_channel"); }, render: function() { @@ -224,8 +223,7 @@ function( // ^ platform's old require.js/esprima breaks on newer syntax in some JS files but not all. if (status === "ready") { // The Unit has been copied and is ready to use. - this.refreshPasteButton(data); // Update our UI - this.clipboardBroadcastChannel.postMessage(data); // And notify any other open tabs + this.clipboardManager.updateUserClipboard(data); // This will update the UI and notify other tabs return data; } else if (status === "loading") { // The clipboard is being loaded asynchronously. @@ -236,8 +234,7 @@ function( // const newStatus = pollData.content?.status; const newStatus = pollData.content && pollData.content.status; if (newStatus === "ready") { - this.refreshPasteButton(data); - this.clipboardBroadcastChannel.postMessage(pollData); + this.clipboardManager.updateUserClipboard(pollData); deferred.resolve(pollData); } else if (newStatus === "loading") { setTimeout(checkStatus, 1000); @@ -258,12 +255,13 @@ function( initializePasteButton(element) { if ($(element).hasClass('outline-subsection')) { - if (this.options.canEdit) { - // We should have the user's clipboard status. - const data = this.model.get("user_clipboard") || {"content": null}; - this.refreshPasteButton(data); - // Refresh the status when something is copied on another tab: - this.clipboardBroadcastChannel.onmessage = (event) => { this.refreshPasteButton(event.data); }; + if (this.options.canEdit && this.clipboardManager) { + // We should have the user's clipboard status from CourseOutlinePage, whose clipboardManager manages + // the clipboard data on behalf of all the XBlocks in the outline. + this.refreshPasteButton(this.clipboardManager.userClipboard); + this.clipboardManager.addEventListener("update", (event) => { + this.refreshPasteButton(event.detail); + }); } else { this.$(".paste-component").hide(); } diff --git a/cms/static/js/views/pages/course_outline.js b/cms/static/js/views/pages/course_outline.js index 1d10fb63752a..a281a9465b8d 100644 --- a/cms/static/js/views/pages/course_outline.js +++ b/cms/static/js/views/pages/course_outline.js @@ -13,6 +13,43 @@ function($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils, var expandedLocators, CourseOutlinePage; + /** + * On the course outline page, many different UI elements (for now, every unit on the page) need to know the status + * of the user's clipboard. This singleton manages the state of the user's clipboard and can emit events whenever + * the clipboard is changed, whether from another tab or some action the user took on this page. + */ + class ClipboardManager extends EventTarget { + constructor(initialUserClipboard) { + super(); + this._userClipboard = initialUserClipboard; + // Refresh the status when something is copied on another tab: + this.clipboardBroadcastChannel = new BroadcastChannel("studio_clipboard_channel"); + this.clipboardBroadcastChannel.onmessage = (event) => { + this.updateUserClipboard(event.data, false); + }; + } + + /** + * Get the data about the user's clipboard. This is exactly the same as + * what would be returned from the "get clipboard" REST API. + */ + get userClipboard() { + return this._userClipboard; + } + + updateUserClipboard(newUserClipboard, broadcast = true) { + this._userClipboard = newUserClipboard; + // Emit an "updated" event so listeners can subscribe. This is different than the broadcast channel + // because this only works within the DOM of a single tab, not across all open tabs that the user has. + // In other words, this even trickles down to each section, subsection, and unit view on the outline page. + this.dispatchEvent(new CustomEvent("update", {detail: newUserClipboard})); + // But also notify listeners on other tabs: + if (broadcast) { + this.clipboardBroadcastChannel.postMessage(newUserClipboard); // And notify any other open tabs + } + } + } + CourseOutlinePage = BasePage.extend({ // takes XBlockInfo as a model @@ -53,6 +90,7 @@ function($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils, $('.dismiss-button').bind('click', ViewUtils.deleteNotificationHandler(function() { $('.wrapper-alert-announcement').removeClass('is-shown').addClass('is-hidden'); })); + this.clipboardManager = new ClipboardManager(this.options.initialUserClipboard); }, setCollapseExpandVisibility: function() { @@ -110,7 +148,8 @@ function($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils, model: this.model, isRoot: true, initialState: this.initialState, - expandedLocators: this.expandedLocators + expandedLocators: this.expandedLocators, + clipboardManager: this.clipboardManager, }); this.outlineView.render(); this.outlineView.setViewState(this.initialState || {}); diff --git a/cms/static/js/views/xblock_outline.js b/cms/static/js/views/xblock_outline.js index 5896d5146683..9ded8d66e91f 100644 --- a/cms/static/js/views/xblock_outline.js +++ b/cms/static/js/views/xblock_outline.js @@ -41,6 +41,7 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldE this.parentView = this.options.parentView; this.renderedChildren = false; this.model.on('sync', this.onSync, this); + this.clipboardManager = this.options.clipboardManager; // May be undefined if not on the course outline page }, render: function() { @@ -220,7 +221,8 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, XBlockStringFieldE parentView: this, initialState: this.initialState, expandedLocators: this.expandedLocators, - template: this.template + template: this.template, + clipboardManager: this.clipboardManager, }, options)); }, diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index 915ec64da488..dfbfd76c499c 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -21,7 +21,8 @@ require(["js/factories/outline"], function (OutlineFactory) { OutlineFactory( ${course_structure | n, dump_js_escaped_json}, - ${initial_state | n, dump_js_escaped_json} + ${initial_state | n, dump_js_escaped_json}, + ${initial_user_clipboard | n, dump_js_escaped_json} ); }); From b69d3fa264131637970c90414b82be18c4bf28cf Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 11 Aug 2023 12:43:38 -0700 Subject: [PATCH 10/12] fix: JS error 'data is undefined' during tests --- cms/static/js/views/pages/course_outline.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cms/static/js/views/pages/course_outline.js b/cms/static/js/views/pages/course_outline.js index a281a9465b8d..28cafc515f43 100644 --- a/cms/static/js/views/pages/course_outline.js +++ b/cms/static/js/views/pages/course_outline.js @@ -70,7 +70,8 @@ function($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils, pollingDelay: 100, options: { - collapsedClass: 'is-collapsed' + collapsedClass: 'is-collapsed', + initialUserClipboard: {content: null}, }, // Extracting this to a variable allows comprehensive themes to replace or extend `CourseOutlineView`. From 7f38ba15af0a749572583a821f5c154fee81bc39 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 11 Aug 2023 14:45:37 -0700 Subject: [PATCH 11/12] fix: remove an unnecessary JS function --- cms/static/js/views/course_outline.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/cms/static/js/views/course_outline.js b/cms/static/js/views/course_outline.js index ba02f475b7ed..d8ae241a37fa 100644 --- a/cms/static/js/views/course_outline.js +++ b/cms/static/js/views/course_outline.js @@ -20,10 +20,6 @@ function( templateName: 'course-outline', - initialize: function() { - XBlockOutlineView.prototype.initialize.call(this); - }, - render: function() { var renderResult = XBlockOutlineView.prototype.render.call(this); this.makeContentDraggable(this.el); @@ -306,7 +302,7 @@ function( } }, - createPlaceholderElementForPaste: function(category, componentDisplayName) { + createPlaceholderElementForPaste(category, componentDisplayName) { const nameStr = StringUtils.interpolate(gettext("Copy of '{componentDisplayName}'"), { componentDisplayName }, true); const el = document.createElement("li"); el.classList.add("outline-item", "outline-" + category, "has-warnings", "is-draggable"); @@ -421,7 +417,7 @@ function( * event, we just toggle displaying the menu. * @param {*} event */ - showActionsMenu: function(event) { + showActionsMenu(event) { const showActionsButton = event.currentTarget; const subMenu = showActionsButton.parentElement.querySelector(".wrapper-nav-sub"); // Code in 'base.js' normally handles toggling these dropdowns but since this one is From 6656e0fde7db57e2ce50f06dbe97c2f03064e083 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 14 Aug 2023 11:15:26 -0700 Subject: [PATCH 12/12] fix: minor typo Co-authored-by: Piotr Surowiec --- cms/static/js/views/pages/course_outline.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/static/js/views/pages/course_outline.js b/cms/static/js/views/pages/course_outline.js index 28cafc515f43..dd1c13eda9fc 100644 --- a/cms/static/js/views/pages/course_outline.js +++ b/cms/static/js/views/pages/course_outline.js @@ -41,7 +41,7 @@ function($, _, gettext, BasePage, XBlockViewUtils, CourseOutlineView, ViewUtils, this._userClipboard = newUserClipboard; // Emit an "updated" event so listeners can subscribe. This is different than the broadcast channel // because this only works within the DOM of a single tab, not across all open tabs that the user has. - // In other words, this even trickles down to each section, subsection, and unit view on the outline page. + // In other words, this event trickles down to each section, subsection, and unit view on the outline page. this.dispatchEvent(new CustomEvent("update", {detail: newUserClipboard})); // But also notify listeners on other tabs: if (broadcast) {