From 231fb6e629a8573f966f8860d01f578c8e07ab87 Mon Sep 17 00:00:00 2001 From: nathanvercaemert <50712356+nathanvercaemert@users.noreply.github.com> Date: Sun, 3 May 2026 06:15:44 -0400 Subject: [PATCH 1/8] Main (#375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Copy Selection Highlight POC (#363) * Add selection info section to copy modal Track browser text selection via selectionchange events and display the highlighted verse range, version, and text snippet in the copy modal. Handles all passage DOM variants (Bible, commentary, interlinear, interleaved/column comparison) and works across desktop and mobile by capturing selection data before modal focus clears it. Co-Authored-By: Claude Opus 4.6 (1M context) * Preserve selection tracking through minification * Initialize selection tracking during page startup * Inline selection verse extraction in startup listener --------- Co-authored-by: Claude Opus 4.6 (1M context) * Show all touched versions in copy selection modal Collect every touched version in the selection summary so compare selections list all visible versions. Known shortcoming: interlinear mode still uses heuristic DOM-based attribution, so version reporting there can be ambiguous and may still need a dedicated rule. * Remove selected text from copy modal handoff * Use passage selections in copy modal * Add note/xref extraction and improved verse matching to copy modal Co-Authored-By: Claude Opus 4.6 (1M context) * feat: multi-version notes/xrefs with inline markers in copy modal Enable notes and cross-references in the copy modal when multiple Bible versions are loaded. Previously blocked by a single-version guard. - Add _fetchNotesForVersions: per-version API fetch with version-labeled end-note sections (e.g. "Notes (ESV):", "Cross references (HCSB):") - Add _injectMarkersIntoClone: inject inline markers (a-ESV), (n1-HCSB) into DOM clone before text extraction, matching end-note IDs - Add _xrefLetter: sequential letter generator for consistent xref IDs across inline markers and end-notes - Fix _getOsisIdsForRange: use .versenumber elements (matching goCopy's clone trimming) instead of .verseLink to fix verse range mismatch in multi-version mode - Update _extractNotesFromHTML to use sequential xref letters matching the inline marker scheme - Update _buildChapterVerseTable to check all loaded versions for notes capability, not just the master version Co-Authored-By: Claude Opus 4.6 (1M context) * feat: move copy button to panel toolbar on mobile On mobile/narrow screens (< 768px), hide the navbar copy icon and show a new copy button in the passage panel toolbar, to the left of the settings cog. On desktop the navbar button remains unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) * feat: per-panel copy dropdown (phases 1-4) behind feature flag Introduce a new per-panel .copyDropdown that mirrors the passage settings cog, covering the full copy flow in-dropdown: - selection mode: snapshot-resolved one-click copy button - grid mode: compact verse-range picker (click-start / click-end) - options strip: version checkboxes + notes/xrefs toggles - status rows: success, cooldown, rapid-copy warning, clipboard-denied, copy-error, unresolved-selection, no-versions The flow coexists with the classic #copyModal during rollout. Enable via ?copyDropdown=1 or localStorage.step.copyDropdownEnabled=true. Key pieces: - view_menu_copy.js: PassageCopyMenuView + step.copyDropdown singleton. Per-panel isolation, mutual exclusion, selection snapshot, listener gating so dropdown clicks do not clobber the underlying native selection. - copy_text.js: _uiSink abstraction so goCopy's three UI touchpoints (success / rapid-warning / no-versions) route through the dropdown instead of #bookchaptermodalbody when the dropdown sink is installed. _isVersionChecked helper and a new optional opts param on goCopy (wantNotes, wantXrefs, checkedVersionIndices) let the dropdown pass choices directly without needing hidden form inputs. - step.util.js: selectionchange listener gates collapse events while the dropdown is open so clicking inside it does not flip deselectedAt. copyModal() checks the flag and routes to the dropdown. - step_ready.js + step.util.js: construct PassageCopyMenuView alongside PassageMenuView for every passage panel. - start.jsp: .copyDropdown markup and script-tag registration. - pom.xml: bundle registration for the minified JS. - InteractiveBundle.properties: new i18n keys for dropdown strings. - copy_dropdown.scss: layout + range-selection styling, RTL and reduced-motion overrides, mobile bottom-sheet. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor: retire classic copy modal (phase 5) The per-panel copy dropdown now replaces #copyModal end-to-end. Remove the feature flag and delete the classic modal's render pipeline. Deletions in copy_text.js (classic-modal-only): initVerseSelect, _displayVerses, _buildSelectionOnlyPanel, _switchToGridMode, _buildChapterVerseTable, _evaluateNotesAvailability _defaultSink (replaced with a no-op fallback inside _sink()) _lastSleepTime The global step.copyText._notesInDOM; single-version note detection is now an inline local var in goCopy. step.util.js: copyModal() drops the ~50 lines of modal DOM construction; it is a 3-line forwarder that clicks the active panel's .copyDropdownToggle. Kept exported for any bookmarked / external JS that invokes it. Removed "copyModal" from modalsRequireUnfreezeOfScroll. view_menu_copy.js lifecycle rewrite: The view now owns open/close directly rather than relying on Bootstrap's data-toggle="dropdown" data-api. Bootstrap's dropdown plugin double-binds click handlers when programmatic $.fn.dropdown("toggle") and declarative data-toggle usage mix, which manifested as open→immediate-close on the very first user click under phase-5 testing. Replaced with: onToggleClick → open() / close() .copyDropdown manages its own .open class document-level click.copyDropdownOutside listener (bound on a 0ms setTimeout so the opening click doesn't self-close it) Also removed: - feature-flag IIFE, isEnabled() method, all flag guards - the $(document).on(".panelCopyBtn", ...) redirect start.jsp: - Deleted (superseded by .copyDropdown which is responsive across all screen sizes). - Removed style="display:none" from .copyDropdown (always visible now). - Removed data-toggle="dropdown" + dropdown-toggle class from the toggle (view owns the click lifecycle). step_ready.js: clipboard-unavailable guard now hides .copyDropdown instead of .panelCopyBtn. Co-Authored-By: Claude Opus 4.7 (1M context) * style: copy-selection modal classes for classic #copyModal fallback Mirror the .copySelection* styling already present in copy_dropdown.scss so the same layout applies when the selection-only flow is rendered inside #copyModal rather than the per-panel dropdown. Covers the container (.copySelectionOnly), range label (.copySelectionRange), primary action button (.copySelectionPrimary, 44px tap-target), escape hint (.copySelectionEscape) and clipboard fallback banner (.copySelectionFallback). Co-Authored-By: Claude Opus 4.7 (1M context) * style: panel-bar color + cleanup for copy dropdown Three refinements to the per-panel copy dropdown: - Selection mode: drop the redundant `

` (the range string is already in the button label) and remove the outer border around the row — let the bordered button stand on its own. - Grid mode: restructure footer as a flex row (Copy left, Back to selection right) and drop the inline `margin-right` on Back; gap is handled by the flex layout. Render order matches visual + tab order. - All three modal action buttons (.copySelectionPrimary, .copyGridPrimary, .copyBackToSelection) now use --clrStrongText for their resting color to match the bible / reference / search panel-bar buttons. Border, padding, hover, and disabled state are still inherited from .copyPrimaryBtn. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: vercaemert Co-authored-by: Claude Opus 4.6 (1M context) --- .../resources/InteractiveBundle.properties | 21 + step-web/pom.xml | 1 + .../js/backbone/views/view_menu_copy.js | 836 ++++++++++++++++++ step-web/src/main/webapp/js/copy_text.js | 530 +++++++---- step-web/src/main/webapp/js/step.util.js | 157 +++- step-web/src/main/webapp/js/step_ready.js | 11 +- .../src/main/webapp/scss/copy_dropdown.scss | 323 +++++++ step-web/src/main/webapp/scss/modals.scss | 30 + .../src/main/webapp/scss/passage_columns.scss | 1 + step-web/src/main/webapp/start.jsp | 12 +- 10 files changed, 1717 insertions(+), 205 deletions(-) create mode 100644 step-web/src/main/webapp/js/backbone/views/view_menu_copy.js create mode 100644 step-web/src/main/webapp/scss/copy_dropdown.scss diff --git a/step-core/src/main/resources/InteractiveBundle.properties b/step-core/src/main/resources/InteractiveBundle.properties index eaee26d884..e8a613cbcb 100644 --- a/step-core/src/main/resources/InteractiveBundle.properties +++ b/step-core/src/main/resources/InteractiveBundle.properties @@ -292,7 +292,28 @@ please_select_first_version_to_copy=Please select the first version to copy select_the_first_verse_to_copy=Select the first verse to copy copy_will_start_from_verse=Copy will start from verse select_last_verse_to_copy=Select the last verse to copy. If you only want to copy one verse, select the same verse again. +copy_your_selection=Copy your selected passage +copy_button_label=Copy +copy_button_aria=Copy +copy_choose_different_range=Pick a different range +copy_selection_not_resolved=We couldn't match your selection to a verse range — please pick below. +selection_range_separator=to text_is_copied=The text is copied, ready to be pasted +copy_dropdown_header=Copy +copy_dropdown_close=Close +copy_dropdown_copy=Copy +copy_dropdown_status_success=The text is copied, ready to be pasted. +copy_dropdown_status_no_versions=You must select at least one version to copy. +copy_dropdown_status_cooldown=Please wait %d seconds before copying again. +copy_dropdown_status_rapid_warning=You are copying at a rapid pace. Please review the copyright terms for: %s. Wait %d seconds. +copy_dropdown_status_copy_error=Copy failed. Please try again. +copy_dropdown_status_clipboard_denied=Clipboard access was denied by the browser. Check permissions or use a secure (https) context. +copy_dropdown_status_ajax_error=Could not load notes for the selected versions. +copy_dropdown_status_stale_passage=The passage changed. Re-open the copy menu. +copy_dropdown_back_to_selection=Back to selection +copy_dropdown_versions_label=Versions +copy_dropdown_include_notes=Include notes +copy_dropdown_include_xrefs=Include cross references stats_occurs_times_in_specific_bible=Occurs %1$d times in %2$s stats_occurs_times_in_specific_ot_nt_bible=Occurs %1$d (OT) %2$d (NT) times in %3$s book_type=Book type diff --git a/step-web/pom.xml b/step-web/pom.xml index ef4adfef40..e24166b2f1 100644 --- a/step-web/pom.xml +++ b/step-web/pom.xml @@ -201,6 +201,7 @@ js/backbone/models/passage_model.js js/backbone/models/model_settings.js js/backbone/views/view_menu_passage.js + js/backbone/views/view_menu_copy.js js/state/step.state.js js/passage/step.passage.js js/backbone/views/view_feedback.js diff --git a/step-web/src/main/webapp/js/backbone/views/view_menu_copy.js b/step-web/src/main/webapp/js/backbone/views/view_menu_copy.js new file mode 100644 index 0000000000..6a38a62945 --- /dev/null +++ b/step-web/src/main/webapp/js/backbone/views/view_menu_copy.js @@ -0,0 +1,836 @@ +/* + * PassageCopyMenuView + * + * Per-panel dropdown that mirrors the settings-cog pattern but drives the + * copy-to-clipboard flow. Coexists with the classic #copyModal during the + * feature-flag-gated rollout. + * + * Modes: + * selection — snapshot resolves to a range in the active panel; shows a + * single "Copy Gen 1:2 → Gen 1:4" primary button. + * grid — no resolved snapshot, or user asked to pick a different + * range; shows a compact verse-number grid with click-start / + * click-end range selection. + */ +window.step = window.step || {}; + +// ------------------------------------------------------------------ +// Cross-panel singleton +// ------------------------------------------------------------------ +step.copyDropdown = step.copyDropdown || { + openPanelId: null, + openView: null, + selectionSnapshot: null, + listenerGated: false, + cooldown: { active: false, until: 0, reason: null, timer: null }, + inFlightCopyId: 0, + + active: function () { return this.openPanelId !== null; }, + shouldSuppressCollapseEvent: function () { return this.listenerGated; }, + + claim: function (panelId, view) { + if (this.openPanelId !== null && this.openPanelId !== panelId && this.openView) { + try { this.openView.dismiss({ silent: true }); } catch (e) { /* ignore */ } + } + this.openPanelId = panelId; + this.openView = view; + this.listenerGated = true; + if (step.lastPassageSelection) { + this.selectionSnapshot = { + startVerse: step.lastPassageSelection.startVerse, + endVerse: step.lastPassageSelection.endVerse, + version: step.lastPassageSelection.version, + versions: (step.lastPassageSelection.versions || []).slice(0), + timestamp: step.lastPassageSelection.timestamp, + deselectedAt: step.lastPassageSelection.deselectedAt, + capturedAt: Date.now() + }; + } else { + this.selectionSnapshot = null; + } + }, + + release: function (panelId) { + if (this.openPanelId !== panelId) return; + this.openPanelId = null; + this.openView = null; + this.selectionSnapshot = null; + this.listenerGated = false; + }, + + startCooldown: function (ms, reason) { + var self = this; + this.cooldown.active = true; + this.cooldown.until = Date.now() + ms; + this.cooldown.reason = reason || "rate"; + if (this.cooldown.timer) clearTimeout(this.cooldown.timer); + this.cooldown.timer = setTimeout(function () { + self.cooldown.active = false; + self.cooldown.timer = null; + if (self.openView && self.openView._onCooldownEnd) self.openView._onCooldownEnd(); + }, ms); + }, + + remainingCooldownMs: function () { + if (!this.cooldown.active) return 0; + var r = this.cooldown.until - Date.now(); + return r < 0 ? 0 : r; + } +}; + +// ------------------------------------------------------------------ +// PassageCopyMenuView +// ------------------------------------------------------------------ +var PassageCopyMenuView = Backbone.View.extend({ + events: { + "click .copyDropdownToggle": "onToggleClick", + "click .copyCloseBtn": "onCloseClick", + "click .copyPrimaryBtn": "onPrimaryClick", + "click .copyPickDifferent": "onPickDifferent", + "click .copyBackToSelection": "onBackToSelection", + "click .copyGridCell": "onGridCellClick", + "change .copyVersionCheckbox": "onVersionToggle", + "change .copyNotesToggle": "onNotesToggle", + "change .copyXrefsToggle": "onXrefsToggle", + "click .copyMenu": "_stopInsideClicks" + }, + + el: function () { + return step.util.getPassageContainer(this.model.get("passageId")).find(".passageOptionsGroup"); + }, + + initialize: function () { + _.bindAll(this); + + this.panelId = this.model.get("passageId"); + this.rendered = false; + this._mode = "selection"; // 'selection' | 'grid' + this._gridStart = null; // verse index + this._gridEnd = null; + + // Navbar #copy-icon delegates to the active panel's dropdown. + // Only bind the redirect once (panelId 0 is always present). + if (this.panelId === 0) { + $("#copy-icon").attr("href", "javascript:void(0)").off("click.copyDropdown") + .on("click.copyDropdown", function (ev) { + ev.preventDefault(); + step.util.copyModal(); + }); + } + + this.listenTo(this.model, "destroy-column", this.remove); + this.listenTo(this.model, "sync-update", this._onPassageSync); + this.listenTo(this.model, "change:reference", this._forceClose); + }, + + // ----- lifecycle ----- + // We own the open/close state directly — not via Bootstrap's data-api. + // Bootstrap's dropdown plugin double-binds click handlers when + // programmatic and declarative ("data-toggle=dropdown") usage mix, which + // caused open→immediate-close on the first user click. Our approach: + // open() adds .open class + fires our own open flow + // close() removes .open + fires our own close flow + // outside-click listener bound on document while open + + onToggleClick: function (ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (this._isOpen()) this.close(); + else this.open(); + }, + + _isOpen: function () { + return this.$el.find(".copyDropdown").hasClass("open"); + }, + + open: function () { + var $dd = this.$el.find(".copyDropdown"); + if ($dd.hasClass("open")) return; + + step.copyDropdown.claim(this.panelId, this); + if (!this.rendered) { + this._initUI(); + this.rendered = true; + } + this._mode = "selection"; + this._gridStart = null; + this._gridEnd = null; + + $dd.addClass("open"); + $dd.find(".copyDropdownToggle").attr("aria-expanded", "true"); + this._update(); + this._bindOutsideClick(); + }, + + close: function () { + var $dd = this.$el.find(".copyDropdown"); + if (!$dd.hasClass("open")) return; + $dd.removeClass("open"); + $dd.find(".copyDropdownToggle").attr("aria-expanded", "false"); + this._unbindOutsideClick(); + step.copyDropdown.release(this.panelId); + }, + + dismiss: function (opts) { + opts = opts || {}; + if (this._isOpen()) this.close(); + else if (!opts.silent) step.copyDropdown.release(this.panelId); + }, + + _bindOutsideClick: function () { + var self = this; + this._outsideHandler = function (ev) { + if (self.$el.find(".copyDropdown").has(ev.target).length === 0) { + // Click outside the dropdown — close, unless cooldown is active. + if (step.copyDropdown.cooldown.active) return; + self.close(); + } + }; + // Defer binding by one event-loop tick: otherwise the very click that + // triggered open() continues bubbling, reaches this handler, and + // closes the dropdown immediately (especially when opened via the + // navbar #copy-icon → copyModal() → $toggle.click() re-dispatch path). + var handler = this._outsideHandler; + setTimeout(function () { + if (self._outsideHandler === handler) { + $(document).on("click.copyDropdownOutside", handler); + } + }, 0); + }, + + _unbindOutsideClick: function () { + if (this._outsideHandler) { + $(document).off("click.copyDropdownOutside", this._outsideHandler); + this._outsideHandler = null; + } + }, + + _forceClose: function () { this.dismiss(); }, + + _onPassageSync: function () { + if (step.copyDropdown.openPanelId === this.panelId) this.dismiss(); + }, + + _stopInsideClicks: function (ev) { ev.stopPropagation(); }, + + remove: function () { + if (step.copyDropdown.openPanelId === this.panelId) step.copyDropdown.release(this.panelId); + if (this._statusTimer) { clearTimeout(this._statusTimer); this._statusTimer = null; } + Backbone.View.prototype.remove.apply(this, arguments); + }, + + // ----- render ----- + + _initUI: function () { + var $dd = this.$el.find(".copyDropdown"); + if ($dd.find(".copyMenu").length === 0) { + var headerTxt = _.escape(__s.copy_dropdown_header || __s.copy_button_label || "Copy"); + var closeLabel = _.escape(__s.copy_dropdown_close || "Close"); + var gridCopyLabel = _.escape(__s.copy_dropdown_copy || __s.copy_button_label || "Copy"); + var html = + '

'; + $dd.append(html); + } + }, + + _update: function () { + this._renderStatusRow(""); + var resolution = this._computeSelectionResolution(); + this._resolution = resolution; + + // Mode policy: if snapshot resolved and user hasn't asked to pick, use + // selection mode. Otherwise grid. + if (this._mode === "selection" && !resolution.resolved) this._mode = "grid"; + + this._renderSelectionRow(resolution); + this._renderGridSection(resolution); + this._renderOptionsStrip(); + this._applyCooldownState(); + }, + + _computeSelectionResolution: function () { + var result = { resolved: false, startIndex: -1, endIndex: -1, label: "", versions: [], unresolvable: false }; + var snap = step.copyDropdown.selectionSnapshot; + if (!snap) return result; + + var now = Date.now(); + var isRecent = (snap.deselectedAt === null && (now - snap.timestamp < 60000)) || + (snap.deselectedAt !== null && (now - snap.deselectedAt < 5000)); + if (!isRecent) return result; + + if ($.isArray(snap.versions) && snap.versions.length > 0) result.versions = snap.versions.slice(0); + else if (snap.version) result.versions = [snap.version]; + + var startVerse = snap.startVerse || ""; + var endVerse = snap.endVerse || snap.startVerse || ""; + if (!startVerse && !endVerse) return result; + + var startDisplay = step.copyText._formatVerseDisplay(startVerse); + var endDisplay = step.copyText._formatVerseDisplay(endVerse); + + var passageContainer = step.util.getPassageContainer(this.panelId); + var verses = step.copyText._getVerses(passageContainer); + var startIdx = step.copyText._findVerseIndex(verses, startDisplay); + var endIdx = step.copyText._findVerseIndex(verses, endDisplay); + if (startIdx === -1 && endIdx > -1) startIdx = endIdx; + if (endIdx === -1 && startIdx > -1) endIdx = startIdx; + + if (startIdx === -1 || endIdx === -1) { + result.unresolvable = true; + result.label = startDisplay || endDisplay || ""; + return result; + } + + result.resolved = true; + result.startIndex = Math.min(startIdx, endIdx); + result.endIndex = Math.max(startIdx, endIdx); + result.label = startDisplay; + if (endDisplay && endDisplay !== startDisplay) { + var sep = " " + (__s.selection_range_separator || "to") + " "; + result.label += sep + endDisplay; + } + return result; + }, + + _renderSelectionRow: function (resolution) { + var $row = this.$el.find(".copySelectionRow"); + var showSelection = resolution.resolved && this._mode === "selection"; + if (!showSelection) { + $row.hide().empty(); + if (resolution.unresolvable && this._mode === "selection") { + this._renderStatusRow(__s.copy_selection_not_resolved || + "We couldn't match your selection — please pick below.", + "unresolved"); + this._mode = "grid"; + } + return; + } + var safeLabel = _.escape(resolution.label || ""); + var btnLabel = (__s.copy_button_label || "Copy") + (safeLabel ? " " + safeLabel : ""); + var pickText = _.escape(__s.copy_choose_different_range || "Pick a different range"); + + var html = + '' + + ''; + $row.html(html).show(); + }, + + _renderGridSection: function (resolution) { + var $section = this.$el.find(".copyGridSection"); + var $footer = this.$el.find(".copyMenuFooter"); + if (this._mode !== "grid") { + $section.hide().empty(); + $footer.hide(); + return; + } + + var passageContainer = step.util.getPassageContainer(this.panelId); + var verses = step.copyText._getVerses(passageContainer); + if (!verses.length) { + $section.hide().empty(); + $footer.hide(); + this._renderStatusRow(__s.copy_dropdown_status_stale_passage || "No verses to pick.", "stale-passage"); + return; + } + + // Determine columns — 10 on desktop, 7 on small touch devices. Same + // heuristic as the classic modal (copy_text.js:_buildChapterVerseTable). + var cols = 10; + if (step.touchDevice) { + var ua = navigator.userAgent.toLowerCase(); + if ((ua.indexOf("android") > -1) || (step.appleTouchDevice && ua.indexOf("safari/60") > -1)) { + cols = 7; + } + } + + // Gather chapter label from first verse's OSIS anchor + var chapterLabel = this._deriveChapterLabel(passageContainer, verses); + + var html = ""; + if (chapterLabel) html += '
' + _.escape(chapterLabel) + '
'; + html += '
'; + var previousVerseName = ""; + for (var i = 0; i < verses.length; i++) { + if (i % cols === 0) html += (i === 0 ? "" : ""); + var orig = verses[i]; + var label = step.copyText._shortenVerseName(previousVerseName, orig); + previousVerseName = orig; + html += ''; + } + html += "
"; + + $section.html(html).show(); + + // Bind keydown in the capture phase at the dropdown level so arrow-key + // navigation handles before any document-level listeners (Bootstrap + // dropdown / IntroJS / etc.) get a chance to process the event. + var self = this; + var $dd = this.$el.find(".copyDropdown")[0]; + if ($dd && !this._gridKeydownBound) { + $dd.addEventListener("keydown", function (ev) { + var cell = ev.target && ev.target.classList && ev.target.classList.contains("copyGridCell") ? ev.target : null; + if (!cell) return; + self.onGridKeydown(ev); + }, true); + this._gridKeydownBound = true; + } + + // Show footer primary button in grid mode. Render order = visual + tab + // order: primary copy chip on the left, back-to-selection chip on the + // right. Flex layout in copy_dropdown.scss handles spacing. + var gridCopyLabel = _.escape(__s.copy_dropdown_copy || __s.copy_button_label || "Copy"); + var backLabel = _.escape(__s.copy_dropdown_back_to_selection || "Back to selection"); + var footerHtml = ''; + if (resolution.resolved) { + footerHtml += ''; + } + $footer.html(footerHtml).show(); + + this._updateGridVisuals(); + }, + + _deriveChapterLabel: function (passageContainer, verses) { + // Prefer the first verseLink's OSIS "Gen.1.1" → "Gen 1" + var firstLink = $(passageContainer).find(".verseLink").first(); + var osis = firstLink.attr("name"); + if (!osis) return ""; + osis = osis.split(" ")[0]; + var parts = osis.split("."); + if (parts.length < 2) return osis; + return parts[0] + " " + parts[1]; + }, + + _updateGridVisuals: function () { + var $cells = this.$el.find(".copyGridCell"); + $cells.each(function () { + $(this).removeAttr("data-role").removeAttr("aria-selected"); + }); + if (this._gridStart === null) { + this.$el.find(".copyGridPrimary").prop("disabled", true).removeAttr("data-start-index data-end-index"); + return; + } + var startIdx = this._gridStart; + var endIdx = (this._gridEnd !== null) ? this._gridEnd : null; + var lo = (endIdx !== null) ? Math.min(startIdx, endIdx) : startIdx; + var hi = (endIdx !== null) ? Math.max(startIdx, endIdx) : startIdx; + for (var i = lo; i <= hi; i++) { + var role = (i === lo) ? "start" : (i === hi ? "end" : "in-range"); + var $cell = $cells.filter('[data-verse-index="' + i + '"]'); + $cell.attr("data-role", role).attr("aria-selected", "true"); + } + var rangeReady = (endIdx !== null); + var $primary = this.$el.find(".copyGridPrimary"); + $primary.prop("disabled", !rangeReady); + if (rangeReady) { + $primary.attr("data-start-index", lo).attr("data-end-index", hi); + } else { + $primary.removeAttr("data-start-index").removeAttr("data-end-index"); + } + }, + + _renderOptionsStrip: function () { + var $strip = this.$el.find(".copyOptionsStrip"); + var self = this; + + var masterVersion = this.model.get("masterVersion"); + var extraVers = this.model.get("extraVersions") || ""; + var hasExtraVersions = extraVers !== ""; + var passageContainer = step.util.getPassageContainer(this.panelId); + var isInterlinear = $(passageContainer).has(".interlinear").length > 0; + + // --- version checkboxes + var versionsHtml = ""; + if (hasExtraVersions && !isInterlinear) { + var allVersions = [masterVersion].concat(extraVers.split(",")); + var checked = this._resolveCheckedVersions(allVersions); + versionsHtml = + '
' + + '' + _.escape(__s.copy_dropdown_versions_label || "Versions") + ''; + for (var i = 0; i < allVersions.length; i++) { + var v = allVersions[i]; + var id = "cpyver-" + this.panelId + "-" + (i + 1); + var isChecked = checked.indexOf(v) > -1; + versionsHtml += + ''; + } + versionsHtml += '
'; + } + + // --- notes / xrefs toggles (conditional on version metadata) + var notesAvailable = this._anyVersionHasNotes(); + var togglesHtml = ""; + if (notesAvailable) { + var wantNotes = !!this.model.get("copyIncludeNotes"); + var wantXrefs = !!this.model.get("copyIncludeXrefs"); + togglesHtml = + '
' + + '' + + '' + + '
'; + } + + var combined = versionsHtml + togglesHtml; + if (combined) { + $strip.html(combined).show(); + } else { + $strip.hide().empty(); + } + }, + + _anyVersionHasNotes: function () { + var masterVersion = this.model.get("masterVersion"); + var extraVers = this.model.get("extraVersions") || ""; + var all = [masterVersion]; + if (extraVers) all = all.concat(extraVers.split(",")); + for (var i = 0; i < all.length; i++) { + var vInfo = step.keyedVersions[all[i]]; + if (vInfo && vInfo.category !== "COMMENTARY" && vInfo.hasNotes) return true; + } + return false; + }, + + _resolveCheckedVersions: function (allVersions) { + // Priority: snapshot.versions[] → persisted model pref → all versions. + var snap = step.copyDropdown.selectionSnapshot; + var persisted = this.model.get("copySelectedVersions"); + if (snap && $.isArray(snap.versions) && snap.versions.length > 0) { + var isect = []; + for (var i = 0; i < snap.versions.length; i++) { + if (allVersions.indexOf(snap.versions[i]) > -1) isect.push(snap.versions[i]); + } + if (isect.length > 0) return isect; + } + if ($.isArray(persisted) && persisted.length > 0) { + var filtered = []; + for (var j = 0; j < persisted.length; j++) { + if (allVersions.indexOf(persisted[j]) > -1) filtered.push(persisted[j]); + } + if (filtered.length > 0) return filtered; + } + return allVersions.slice(0); + }, + + _collectCheckedVersionIndices: function () { + var out = []; + this.$el.find(".copyVersionCheckbox").each(function () { + if ($(this).prop("checked")) out.push(parseInt($(this).attr("data-version-index"), 10)); + }); + return out; + }, + + _collectCheckedVersionNames: function () { + var out = []; + this.$el.find(".copyVersionCheckbox").each(function () { + if ($(this).prop("checked")) out.push($(this).attr("data-version")); + }); + return out; + }, + + _renderStatusRow: function (text, kind) { + var $row = this.$el.find(".copyStatusRow"); + $row.removeClass(function (_i, c) { + return (c.match(/copyStatus--\S+/g) || []).join(" "); + }); + if (!text) { $row.empty().attr("role", "status"); return; } + if (kind) $row.addClass("copyStatus--" + kind); + $row.attr("role", (kind === "copy-error" || kind === "clipboard-denied" || + kind === "ajax-error" || kind === "stale-passage") + ? "alert" : "status"); + $row.text(text); + }, + + _applyCooldownState: function () { + var remaining = step.copyDropdown.remainingCooldownMs(); + var $primary = this.$el.find(".copyPrimaryBtn"); + var $close = this.$el.find(".copyCloseBtn"); + if (remaining > 0) { + $primary.prop("disabled", true); + $close.prop("disabled", true); + var secs = Math.ceil(remaining / 1000); + var msg = (__s.copy_dropdown_status_cooldown || + "Please wait %d seconds before copying again.").replace("%d", secs); + this._renderStatusRow(msg, "cooldown"); + } else { + $primary.prop("disabled", false); + $close.prop("disabled", false); + } + }, + + _onCooldownEnd: function () { + if (step.copyDropdown.openPanelId !== this.panelId) return; + this._renderStatusRow(""); + this.$el.find(".copyPrimaryBtn").prop("disabled", false); + this.$el.find(".copyCloseBtn").prop("disabled", false); + this._updateGridVisuals(); // re-enable grid primary only if range ready + }, + + // ----- user interactions ----- + + onCloseClick: function (ev) { + ev.preventDefault(); ev.stopPropagation(); + if (step.copyDropdown.cooldown.active) return; + this.dismiss(); + }, + + onPrimaryClick: function (ev) { + ev.preventDefault(); ev.stopPropagation(); + if (step.copyDropdown.cooldown.active) return; + var $btn = $(ev.currentTarget); + if ($btn.prop("disabled")) return; + var startIndex = parseInt($btn.attr("data-start-index"), 10); + var endIndex = parseInt($btn.attr("data-end-index"), 10); + if (isNaN(startIndex) || isNaN(endIndex)) return; + + // Validation: if version fieldset is visible, require ≥1 checked + var $versionField = this.$el.find(".copyVersions"); + if ($versionField.length && this._collectCheckedVersionIndices().length === 0) { + this._renderStatusRow( + __s.copy_dropdown_status_no_versions || "Select at least one version.", + "no-versions"); + return; + } + this._invokeGoCopy(startIndex, endIndex, $btn); + }, + + onPickDifferent: function (ev) { + ev.preventDefault(); ev.stopPropagation(); + this._mode = "grid"; + this._update(); + // Focus first cell for keyboard users + var $first = this.$el.find(".copyGridCell").first(); + if ($first.length) $first.focus(); + }, + + onBackToSelection: function (ev) { + ev.preventDefault(); ev.stopPropagation(); + this._mode = "selection"; + this._gridStart = null; + this._gridEnd = null; + this._update(); + }, + + onGridCellClick: function (ev) { + ev.preventDefault(); ev.stopPropagation(); + var idx = parseInt($(ev.currentTarget).attr("data-verse-index"), 10); + if (isNaN(idx)) return; + this._handleGridSelect(idx); + }, + + _handleGridSelect: function (idx) { + if (this._gridStart === null) { + this._gridStart = idx; + this._gridEnd = null; + } else if (this._gridEnd === null) { + this._gridEnd = idx; + } else { + // Third click — reset to new start + this._gridStart = idx; + this._gridEnd = null; + } + this._updateGridVisuals(); + }, + + onGridKeydown: function (ev) { + // ev is the native DOM event (bound directly in _renderGridSection). + var cell = ev.currentTarget; + var idx = parseInt(cell.getAttribute("data-verse-index"), 10); + if (ev.which === 27 /* Esc */) { + ev.preventDefault(); + ev.stopPropagation(); + if (this._gridStart !== null) { + this._gridStart = null; + this._gridEnd = null; + this._updateGridVisuals(); + } else { + this.dismiss(); + } + return; + } + if (isNaN(idx)) return; + var $cells = this.$el.find(".copyGridCell"); + var total = $cells.length; + var cols = this._gridColumnCount(); + var target = null; + switch (ev.which) { + case 13: /* Enter */ + case 32: /* Space */ + ev.preventDefault(); + ev.stopPropagation(); + this._handleGridSelect(idx); + return; + case 37: /* Left */ target = idx - 1; break; + case 39: /* Right */ target = idx + 1; break; + case 38: /* Up */ target = idx - cols; break; + case 40: /* Down */ target = idx + cols; break; + case 36: /* Home */ target = idx - (idx % cols); break; + case 35: /* End */ target = Math.min(idx - (idx % cols) + cols - 1, total - 1); break; + default: return; + } + ev.preventDefault(); + ev.stopPropagation(); + if (target < 0 || target >= total) return; + var $target = $cells.filter('[data-verse-index="' + target + '"]'); + // Keep all cells at tabindex="0" so tabindex churn doesn't steal focus + // (Chromium loses focus if the element that just got focus has its + // tabindex rotated around it synchronously in the same event loop). + $target[0].focus(); + }, + + _gridColumnCount: function () { + var $table = this.$el.find(".copyGrid"); + if (!$table.length) return 10; + return parseInt($table.attr("aria-colcount"), 10) || 10; + }, + + onVersionToggle: function (ev) { + ev.stopPropagation(); + var names = this._collectCheckedVersionNames(); + // Persist; validation happens at copy time + this.model.save({ copySelectedVersions: names }, { silent: true }); + // Clear a transient no-versions status if the user just re-checked one + var $row = this.$el.find(".copyStatusRow"); + if ($row.hasClass("copyStatus--no-versions") && names.length > 0) this._renderStatusRow(""); + }, + + onNotesToggle: function (ev) { + ev.stopPropagation(); + this.model.save({ copyIncludeNotes: $(ev.currentTarget).prop("checked") }, { silent: true }); + }, + + onXrefsToggle: function (ev) { + ev.stopPropagation(); + this.model.save({ copyIncludeXrefs: $(ev.currentTarget).prop("checked") }, { silent: true }); + }, + + // ----- goCopy invocation ----- + + _invokeGoCopy: function (startIndex, endIndex, $btn) { + var self = this; + var copyId = ++step.copyDropdown.inFlightCopyId; + $btn.prop("disabled", true); + + // Construct opts so goCopy doesn't need to read #selectnotes / #cpyverN + var opts = {}; + if (this._anyVersionHasNotes()) { + opts.wantNotes = !!this.model.get("copyIncludeNotes"); + opts.wantXrefs = !!this.model.get("copyIncludeXrefs"); + } else { + opts.wantNotes = false; + opts.wantXrefs = false; + } + // Version indices — only if version fieldset is rendered + if (this.$el.find(".copyVersionCheckbox").length > 0) { + opts.checkedVersionIndices = this._collectCheckedVersionIndices(); + } + + var prevSink = step.copyText._uiSink; + step.copyText._uiSink = { + showSuccess: function () { + if (copyId !== step.copyDropdown.inFlightCopyId) return; + self._onCopySuccess(); + }, + showRapidWarning: function (versionsString, sleepMs) { + if (copyId !== step.copyDropdown.inFlightCopyId) return; + self._onRapidWarning(versionsString, sleepMs); + }, + showNoVersionsSelected: function () { + if (copyId !== step.copyDropdown.inFlightCopyId) return; + self._renderStatusRow( + __s.copy_dropdown_status_no_versions || + "You must select at least one version to copy.", + "no-versions"); + }, + showCopyError: function (err) { + if (copyId !== step.copyDropdown.inFlightCopyId) return; + self._renderStatusRow(__s.copy_dropdown_status_copy_error || "Copy failed.", "copy-error"); + }, + showClipboardDenied: function () { + if (copyId !== step.copyDropdown.inFlightCopyId) return; + self._renderStatusRow( + __s.copy_dropdown_status_clipboard_denied || + "Clipboard access was denied by the browser.", + "clipboard-denied"); + } + }; + + try { + step.copyText.goCopy(startIndex, endIndex, opts); + } catch (e) { + if (step.copyText._uiSink && step.copyText._uiSink.showCopyError) { + step.copyText._uiSink.showCopyError(e); + } + } finally { + setTimeout(function () { step.copyText._uiSink = prevSink; }, 0); + } + }, + + _onCopySuccess: function () { + var self = this; + var msg = __s.copy_dropdown_status_success || __s.text_is_copied || "The text is copied."; + this._renderStatusRow(msg, "success"); + this.$el.find(".copyPrimaryBtn").prop("disabled", true); + if (this._statusTimer) clearTimeout(this._statusTimer); + this._statusTimer = setTimeout(function () { + self._renderStatusRow(""); + // Re-enable primary button if in selection mode; grid mode uses its range state + if (self._mode === "selection") self.$el.find(".copyPrimaryBtn").prop("disabled", false); + else self._updateGridVisuals(); + self._statusTimer = null; + }, 1500); + }, + + _onRapidWarning: function (versionsString, sleepMs) { + step.copyDropdown.startCooldown(sleepMs || 5000, "rate"); + var secs = Math.ceil((sleepMs || 5000) / 1000); + var template = __s.copy_dropdown_status_rapid_warning || + "You are copying at a rapid pace. Please review the copyright terms for: %s. Wait %d seconds."; + var msg = template.replace("%s", versionsString).replace("%d", secs); + this._renderStatusRow(msg, "rapid-warning"); + this.$el.find(".copyPrimaryBtn").prop("disabled", true); + this.$el.find(".copyCloseBtn").prop("disabled", true); + } +}); + +window.PassageCopyMenuView = PassageCopyMenuView; diff --git a/step-web/src/main/webapp/js/copy_text.js b/step-web/src/main/webapp/js/copy_text.js index d0ce06c95b..fda227659c 100644 --- a/step-web/src/main/webapp/js/copy_text.js +++ b/step-web/src/main/webapp/js/copy_text.js @@ -1,55 +1,287 @@ window.step = window.step || {}; step.copyText = { - initVerseSelect: function() { - step.util.closeModal('searchSelectionModal'); - step.util.closeModal('passageSelectionModal'); - var extraVers = step.util.activePassage().get("extraVersions"); - this._displayVerses(extraVers !== ""); // does it have extra versions, more than one Bible version - if ((extraVers !== "") && - (step.util.getPassageContainer(step.util.activePassageId()).has(".interlinear").length == 0)) { - var lastCopyRightsVersions = $.cookie("step.copyRightsVersions"); - var versionsToExclude = []; - if (typeof lastCopyRightsVersions === "string") { - var masterVersion = step.util.activePassage().get("masterVersion"); - var versionsString = masterVersion + "," + extraVers; - var parts = lastCopyRightsVersions.split("@"); - if (parts[0] === versionsString) { - if (parts.length == 2) - versionsToExclude = parts[1].split(","); + // UI sink — the copy dropdown (view_menu_copy.js) installs a sink for the + // duration of a copy so goCopy's status-producing calls route into the + // dropdown. If nothing is installed, sink calls are no-ops — goCopy still + // writes to the clipboard, but success/error reporting is silently dropped. + _sink: function () { + return this._uiSink || { + showSuccess: function () {}, + showRapidWarning: function () {}, + showNoVersionsSelected: function () {}, + showCopyError: function () {}, + showClipboardDenied: function () {} + }; + }, + + _getSelectionState: function() { + var selInfo = step.lastPassageSelection; + var result = { + hasSelection: false, + versions: [], + startVerse: '', + endVerse: '', + startVerseDisplay: '', + endVerseDisplay: '', + startIndex: -1, + endIndex: -1, + label: '' + }; + if (!selInfo) return result; + var now = Date.now(); + var isRecent = (selInfo.deselectedAt === null && (now - selInfo.timestamp < 60000)) || + (selInfo.deselectedAt !== null && (now - selInfo.deselectedAt < 5000)); + if (!isRecent) return result; + if ($.isArray(selInfo.versions) && selInfo.versions.length > 0) + result.versions = selInfo.versions.slice(0); + else if (selInfo.version) + result.versions = [selInfo.version]; + result.startVerse = selInfo.startVerse || ''; + result.endVerse = selInfo.endVerse || selInfo.startVerse || ''; + result.startVerseDisplay = this._formatVerseDisplay(result.startVerse); + result.endVerseDisplay = this._formatVerseDisplay(result.endVerse); + result.hasSelection = (result.versions.length > 0 || result.startVerse !== '' || result.endVerse !== ''); + if (result.hasSelection) { + result.label = result.startVerseDisplay || ''; + if (result.endVerseDisplay && result.endVerseDisplay !== result.startVerseDisplay) { + var sep = ' ' + (__s.selection_range_separator || 'to') + ' '; + result.label += sep + result.endVerseDisplay; + } + if (result.label === '') result.label = 'your current selection'; + } + return result; + }, + + _formatVerseDisplay: function(osis) { + if (!osis) return ''; + return osis.replace(/^([123A-Za-z]+)\.(\d)/, '$1 $2').replace(/\.(\d+)/g, ': $1'); + }, + + _normalizeVerseLabel: function(verseLabel) { + return (verseLabel || '').replace(/\s+/g, '').toLowerCase(); + }, + + _findVerseIndex: function(verses, verseDisplay) { + var normalizedTarget = this._normalizeVerseLabel(verseDisplay); + for (var i = 0; i < verses.length; i++) { + if (this._normalizeVerseLabel(verses[i]) === normalizedTarget) + return i; + } + // Verse grid may contain short labels like "1" while verseDisplay + // is fully qualified like "Gen 1: 1" — try matching just the verse number + var verseNumMatch = verseDisplay.match(/:?\s*(\d+)\s*$/); + if (verseNumMatch) { + var verseNum = verseNumMatch[1]; + for (var i = 0; i < verses.length; i++) { + if (verses[i].trim() === verseNum) + return i; + } + } + return -1; + }, + + _getOsisIdsForRange: function(passageContainer, firstVerseIndex, lastVerseIndex) { + // Use versenumber/verselink elements (same as goCopy's clone trimming) to find + // the verse containers, then extract OSIS from the nearest verseLink + var verses = $(passageContainer).find('.versenumber'); + if (verses.length == 0) verses = $(passageContainer).find('.verselink'); + var firstOsis = ''; + var lastOsis = ''; + if (verses.length > firstVerseIndex) { + var container = $(verses[firstVerseIndex]).closest('.verseGrouping, .verse, .interlinear'); + var link = container.find('.verseLink').first(); + firstOsis = (link.attr('name') || '').split(' ')[0]; + } + if (verses.length > lastVerseIndex) { + var container = $(verses[lastVerseIndex]).closest('.verseGrouping, .verse, .interlinear'); + var link = container.find('.verseLink').first(); + lastOsis = (link.attr('name') || '').split(' ')[0]; + } + return { first: firstOsis, last: lastOsis }; + }, + + _extractNotesFromClone: function(copyOfPassage, wantNotes, wantXrefs) { + var endNotes = ""; + var endXrefs = ""; + var notes = $(copyOfPassage).find('.note'); + var noteCounter = 0; + for (var l = 0; l < notes.length; l++) { + var aTag = $(notes[l]).find("a"); + if (wantNotes && aTag.length > 1) { + noteCounter++; + var noteID = "n" + noteCounter; + var refs = $(notes[l]).find(".inlineNote").text().replace(/▼/, ""); + $("(" + noteID + ") ").insertAfter(notes[l]); + if (refs) endNotes += "\n(" + noteID + ") " + refs; + } + if (wantXrefs && aTag.length == 1) { + var noteID = $(aTag).text(); + var refs = ""; + var margins = $(copyOfPassage).find('.margin'); + for (var m = 0; m < margins.length; m++) { + if (noteID === $(margins[m]).find("strong").text()) { + var linkRefs = $(margins[m]).find(".linkRef"); + for (var n = 0; n < linkRefs.length; n++) { + if (n > 0) refs += ", "; + refs += $(linkRefs[n]).text(); + } + break; + } + } + if (refs !== "") { + $("(" + noteID + ") ").insertAfter(notes[l]); + endXrefs += "\n(" + noteID + ") " + refs; } } - var checkedOrNot = (!versionsToExclude.includes('0')) ? "checked " : ""; - var checkboxHTML = '' + - ''; - var otherVers = extraVers.split(","); - for (var i = 0; i < otherVers.length; i++) { - var j = i + 2; - var k = i + 1; - var checkedOrNot = (!versionsToExclude.includes(k.toString())) ? "checked " : ""; - checkboxHTML += ' ' + - ''; + } + return { endNotes: endNotes, endXrefs: endXrefs }; + }, + + _extractNotesFromHTML: function(html, firstOsis, lastOsis) { + var $html = $(html); + var endNotes = ""; + var endXrefs = ""; + + // Build set of OSIS IDs within the copied range + var verseLinks = $html.find('.verseLink'); + var inRange = false; + var osisIdsInRange = {}; + for (var i = 0; i < verseLinks.length; i++) { + var name = ($(verseLinks[i]).attr('name') || '').split(' ')[0]; + if (name === firstOsis) inRange = true; + if (inRange) osisIdsInRange[name] = true; + if (name === lastOsis) break; + } + + var notes = $html.find('.note'); + var noteCounter = 0; + var xrefCounter = 0; + for (var l = 0; l < notes.length; l++) { + var noteEl = $(notes[l]); + // Walk up to find the verse container, then its verseLink + var verseContainer = noteEl.closest('.verse, .verseGrouping, .interlinear, .commentaryVerse'); + var verseLink = verseContainer.find('.verseLink').first(); + var osisId = (verseLink.attr('name') || '').split(' ')[0]; + + // Skip notes outside the copied range + if (osisId && !osisIdsInRange[osisId]) continue; + + var aTag = noteEl.find("a"); + if (aTag.length > 1) { + // Footnote + noteCounter++; + var noteID = "n" + noteCounter; + var refs = noteEl.find(".inlineNote").text().replace(/▼/, ""); + if (refs) endNotes += "\n(" + noteID + ") " + refs; + } else if (aTag.length == 1) { + // Cross-reference — use sequential letter matching _injectMarkersIntoClone + var nativeID = $(aTag).text(); + var seqID = step.copyText._xrefLetter(xrefCounter); + xrefCounter++; + var refs = ""; + var margins = $html.find('.margin'); + for (var m = 0; m < margins.length; m++) { + if (nativeID === $(margins[m]).find("strong").text()) { + var linkRefs = $(margins[m]).find(".linkRef"); + for (var n = 0; n < linkRefs.length; n++) { + if (n > 0) refs += ", "; + refs += $(linkRefs[n]).text(); + } + break; + } + } + if (refs !== "") endXrefs += "\n(" + seqID + ") " + refs; } - $('#selectversionstocopy').html("

Versions to copy:

 " + checkboxHTML); } - else - $('#selectversionstocopy').remove(); + return { endNotes: endNotes, endXrefs: endXrefs }; }, - _displayVerses: function(hasExtraVersions) { - $('#bookchaptermodalbody').empty(); - var html = this._buildHeaderAndSkeleton(); - $('#bookchaptermodalbody').append(html); - $('#bookchaptermodalbody').append(this._buildChapterVerseTable(-1, hasExtraVersions)); + _xrefLetter: function(n) { + // 0->a, 1->b, ..., 25->z, 26->aa, etc. + var s = ""; + do { + s = String.fromCharCode(97 + (n % 26)) + s; + n = Math.floor(n / 26) - 1; + } while (n >= 0); + return s; }, + _injectMarkersIntoClone: function(copyOfPassage, checkedVersions, wantNotes, wantXrefs) { + var noteCounterByVersion = {}; + var xrefCounterByVersion = {}; + var labelVersions = checkedVersions.length > 1; + var notes = $(copyOfPassage).find('.note'); + for (var l = 0; l < notes.length; l++) { + var noteEl = $(notes[l]); + // In multi-version DOM, structure is: span.singleVerse > span[data-version] + div.verse + // Notes are inside div.verse, so find the parent singleVerse and its data-version child + var singleVerse = noteEl.closest('.singleVerse'); + var versionSpan = singleVerse.find('span[data-version]').first(); + var version = versionSpan.attr('data-version'); + if (!version || checkedVersions.indexOf(version) === -1) continue; + var vInfo = step.keyedVersions[version]; + if (!vInfo || !vInfo.hasNotes || vInfo.category === "COMMENTARY") continue; + var aTag = noteEl.find("a"); + if (wantNotes && aTag.length > 1) { + if (!noteCounterByVersion[version]) noteCounterByVersion[version] = 0; + noteCounterByVersion[version]++; + var noteID = "n" + noteCounterByVersion[version]; + var marker = labelVersions ? "(" + noteID + "-" + version + ") " : "(" + noteID + ") "; + $("" + marker + "").insertAfter(noteEl); + } + if (wantXrefs && aTag.length == 1) { + if (!xrefCounterByVersion[version]) xrefCounterByVersion[version] = 0; + var noteID = step.copyText._xrefLetter(xrefCounterByVersion[version]); + xrefCounterByVersion[version]++; + var marker = labelVersions ? "(" + noteID + "-" + version + ") " : "(" + noteID + ") "; + $("" + marker + "").insertAfter(noteEl); + } + } + }, - _buildHeaderAndSkeleton: function() { - var html = '
' + - '

' + __s.please_select_first_version_to_copy + '

'; - return html; + _fetchNotesForVersions: function(versions, reference, firstOsis, lastOsis, wantNotes, wantXrefs) { + var result = { notesByVersion: {}, errors: [] }; + for (var i = 0; i < versions.length; i++) { + var version = versions[i]; + var vInfo = step.keyedVersions[version]; + if (!vInfo || !vInfo.hasNotes || vInfo.category === "COMMENTARY") continue; + var fetchedHTML = null; + try { + $.ajaxSetup({async: false}); + $.getJSON(BIBLE_GET_BIBLE_TEXT + version + "/" + encodeURIComponent(reference) + "/NHV//", function(data) { + fetchedHTML = data.value; + }); + $.ajaxSetup({async: true}); + } catch (e) { + $.ajaxSetup({async: true}); + result.errors.push(version); + continue; + } + if (fetchedHTML && firstOsis) { + var noteData = step.copyText._extractNotesFromHTML(fetchedHTML, firstOsis, lastOsis); + if ((wantNotes && noteData.endNotes) || (wantXrefs && noteData.endXrefs)) + result.notesByVersion[version] = noteData; + } else if (!fetchedHTML) { + result.errors.push(version); + } + } + return result; }, - goCopy: function(firstVerseIndex, lastVerseIndex) { + // Helper used by both the modal's DOM-driven path and the dropdown's + // options-driven path. Returns true iff the caller wants version N (0-based). + _isVersionChecked: function(opts, n) { + if (opts && $.isArray(opts.checkedVersionIndices)) + return opts.checkedVersionIndices.indexOf(n) > -1; + return $('#cpyver' + (n + 1)).prop('checked'); + }, + + // goCopy(firstVerseIndex, lastVerseIndex, opts?) + // opts.wantNotes (bool) — overrides the #selectnotes checkbox + // opts.wantXrefs (bool) — overrides the #selectxref checkbox + // opts.checkedVersionIndices (int[]) — overrides #cpyver1..N probes + goCopy: function(firstVerseIndex, lastVerseIndex, opts) { + opts = opts || {}; var passageContainer = step.util.getPassageContainer(step.util.activePassageId()); var copyOfPassage = $(passageContainer).find(".passageContentHolder").clone(); if (firstVerseIndex > lastVerseIndex) { @@ -92,45 +324,58 @@ step.copyText = { } } } + var masterVersion = step.util.activePassage().get("masterVersion"); + var extraVersions = step.util.activePassage().get("extraVersions"); + var hasExtraVersions = (extraVersions !== ""); + var isInterlinear = $(passageContainer).has(".interlinear").length > 0; var endNotes = ""; - if ($("#selectnotes").prop("checked")) { - var notes = $(copyOfPassage).find('.note'); - for (var l = 0; l < notes.length; l++) { - var aTag = $(notes[l]).find("a"); - if (aTag.length > 1) { - noteID = "n" + (l + 1); // The notes number will start with 1, not zero. - refs = $(notes[l]).find(".inlineNote").text().replace(/▼/, ""); - $("(" + noteID + ") ").insertAfter(notes[l]); - endNotes += "\n(" + noteID + ") " + refs; - } - } - } var endXrefs = ""; - if ($("#selectxref").prop("checked")) { - var notes = $(copyOfPassage).find('.note'); - for (var l = 0; l < notes.length; l++) { - var aTag = $(notes[l]).find("a"); - if (aTag.length == 1) { - var noteID = $(aTag).text(); - var refs = ""; - var margins = $(".margin"); - if (margins.length > 0) { - for (var m = 0; m < margins.length; m++) { - if (noteID === $(margins[m]).find("strong").text()) { - var linkRefs = $(margins[m]).find(".linkRef"); - for (var n = 0; n < linkRefs.length; n ++) { - if (n > 0) refs += ", "; - refs += $(linkRefs[n]).text(); - } - continue; - } - } + var wantNotes = (opts.wantNotes !== undefined) ? !!opts.wantNotes : $("#selectnotes").prop("checked"); + var wantXrefs = (opts.wantXrefs !== undefined) ? !!opts.wantXrefs : $("#selectxref").prop("checked"); + if (wantNotes || wantXrefs) { + var reference = step.util.activePassage().get("reference"); + var osisRange = step.copyText._getOsisIdsForRange(passageContainer, firstVerseIndex, lastVerseIndex); + // Single-version panels may already have .note elements rendered + // inline, so we can extract from the clone without a fresh fetch. + var notesInDOM = !hasExtraVersions && !isInterlinear && + $(passageContainer).find('.note').length > 0; + if (!hasExtraVersions && notesInDOM) { + // Single version fast path: notes already in DOM clone + var noteData = step.copyText._extractNotesFromClone(copyOfPassage, wantNotes, wantXrefs); + if (wantNotes && noteData.endNotes) endNotes = "\nNotes:" + noteData.endNotes; + if (wantXrefs && noteData.endXrefs) endXrefs = "\nCross references:" + noteData.endXrefs; + } else { + // API path: single version without DOM notes, or multi-version + var versionsForNotes; + if (!hasExtraVersions || isInterlinear) { + versionsForNotes = [masterVersion]; + } else { + var allVersions = [masterVersion].concat(extraVersions.split(",")); + versionsForNotes = []; + for (var n = 0; n < allVersions.length; n++) { + if (step.copyText._isVersionChecked(opts, n)) + versionsForNotes.push(allVersions[n]); } } - if (refs !== "") { - $("(" + noteID + ") ").insertAfter(notes[l]); - endXrefs += "\n(" + noteID + ") " + refs; + var noteResult = step.copyText._fetchNotesForVersions( + versionsForNotes, reference, osisRange.first, osisRange.last, wantNotes, wantXrefs + ); + var versionKeys = []; + for (var key in noteResult.notesByVersion) { + if (noteResult.notesByVersion.hasOwnProperty(key)) + versionKeys.push(key); + } + var labelVersions = versionKeys.length > 1; + for (var v = 0; v < versionKeys.length; v++) { + var ver = versionKeys[v]; + var nd = noteResult.notesByVersion[ver]; + if (wantNotes && nd.endNotes) + endNotes += "\n" + (labelVersions ? "Notes (" + ver + "):" : "Notes:") + nd.endNotes; + if (wantXrefs && nd.endXrefs) + endXrefs += "\n" + (labelVersions ? "Cross references (" + ver + "):" : "Cross references:") + nd.endXrefs; } + // Inject inline markers into clone before notes are stripped + step.copyText._injectMarkersIntoClone(copyOfPassage, versionsForNotes, wantNotes, wantXrefs); } } @@ -177,8 +422,7 @@ step.copyText = { for (var n = 0; n < elementsWithSmallCapsClases.length; n ++) { $(elementsWithSmallCapsClases[n]).text($(elementsWithSmallCapsClases[n]).text().toUpperCase()); } - var versionsString = step.util.activePassage().get("masterVersion"); - var extraVersions = step.util.activePassage().get("extraVersions"); + var versionsString = masterVersion; var options = step.util.activePassage().get("options"); var versions = versionsString.split(","); var versionsToExclude = []; @@ -187,7 +431,7 @@ step.copyText = { versionsString += "," + extraVersions; versions = versionsString.split(","); for (var n = 0; n < versions.length; n++) { - if ($('#cpyver' + (n + 1)).prop('checked')) + if (step.copyText._isVersionChecked(opts, n)) numOfSelected ++; else { $(copyOfPassage).find('span[data-version="' + versions[n] + '"]').next().remove(); @@ -196,10 +440,7 @@ step.copyText = { } } if ((numOfSelected == 0) && (interlinearClasses.length == 0)) { // error message only apply for non-interlinear mode - $('#bookchaptermodalbody').empty(); - $('#bookchaptermodalbody').append("

You must select at least one version to copy."); - $('#copyModalFooter').empty(); - setTimeout( function() { step.util.closeModal("copyModal")}, 3000); + step.copyText._sink().showNoVersionsSelected(); return; } else if (numOfSelected == 1) @@ -304,8 +545,8 @@ step.copyText = { textToCopy += lines[i] + "\n"; } } - if (endNotes !== "") textToCopy += "\nNotes:" + endNotes; - if (endXrefs !== "") textToCopy += "\nCross references:" + endXrefs; + if (endNotes !== "") textToCopy += endNotes; + if (endXrefs !== "") textToCopy += endXrefs; var currentTimeInSeconds = Math.floor( new Date().getTime() / 1000 ); @@ -355,19 +596,53 @@ step.copyText = { } var sleepTime = 1000; $.cookie("step.copyTimeStamps", timeStampForNewCookie); - if (copiesInLastMinute > 4) { - alert("You are copying at a rapid pace.\n\nThe copy function is intended for personal use within the copyrights limitation. Please review the copyrights requirement for the Bibles (" + - versionsString + - ") you are using."); + var rapidCopy = copiesInLastMinute > 4; + if (rapidCopy) { sleepTime = Math.min((60 - longestDifference) * 1000, 5000); - $("#copyModal").find('.close').hide(); } else if (previousTimes.length > 0) sleepTime = 600; - navigator.clipboard.writeText(textToCopy); - $('#bookchaptermodalbody').empty(); - $('#bookchaptermodalbody').append("

" + __s.text_is_copied); - $('#copyModalFooter').empty(); - setTimeout( function() { step.util.closeModal("copyModal")}, sleepTime); + + // Clipboard write — guarded so dropdown sink can surface clipboard errors. + try { + var writeResult = navigator.clipboard && navigator.clipboard.writeText + ? navigator.clipboard.writeText(textToCopy) : null; + if (writeResult && typeof writeResult.then === "function") { + writeResult["catch"](function () { + step.copyText._sink().showClipboardDenied(); + }); + } else if (!navigator.clipboard || !navigator.clipboard.writeText) { + // Insecure-context fallback: hidden textarea + execCommand + var ta = document.createElement("textarea"); + ta.value = textToCopy; + ta.setAttribute("readonly", ""); + ta.style.position = "absolute"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + var prevSel = document.getSelection(); + var prevRanges = []; + if (prevSel && prevSel.rangeCount) { + for (var r = 0; r < prevSel.rangeCount; r++) prevRanges.push(prevSel.getRangeAt(r)); + } + ta.select(); + try { document.execCommand("copy"); } + catch (e) { step.copyText._sink().showClipboardDenied(); } + document.body.removeChild(ta); + // Restore any previous ranges + if (prevSel) { + prevSel.removeAllRanges(); + for (var r = 0; r < prevRanges.length; r++) prevSel.addRange(prevRanges[r]); + } + } + } catch (e) { + step.copyText._sink().showCopyError(e); + return; + } + + if (rapidCopy) { + step.copyText._sink().showRapidWarning(versionsString, sleepTime); + } else { + step.copyText._sink().showSuccess(); + } }, _shortenVerseName: function(previousVerseName, verseName) { var verseSplit = verseName.split(/:/); @@ -395,76 +670,5 @@ step.copyText = { } } return verses; - }, - _buildChapterVerseTable: function(firstSelection, hasExtraVersions) { - var passageContainer = step.util.getPassageContainer(step.util.activePassageId()); - var verses = step.copyText._getVerses(passageContainer); - var hasXRefs = false; - var hasNotes = false; - if (!hasExtraVersions) { // The notes and xrefs from different versions should not be mixed. - var notes = $(passageContainer).find('.note'); - for (var l = 0; ((l < notes.length) && (!hasXRefs || (!hasNotes))); l++) { - var aTag = $(notes[l]).find("a"); - if ((aTag.length == 1) && (!hasXRefs)) { - $("#includeXRefs").show(); - hasXRefs = true; - } - else if ((aTag.length > 1) && (!hasNotes)) { - $("#includeNotes").show(); - hasNotes = true; - } - } - } - var headerMsg = (firstSelection == -1) ? __s.select_the_first_verse_to_copy + "


" : - __s.copy_will_start_from_verse + ": " + verses[firstSelection] + "
" + __s.select_last_verse_to_copy; - this.modalMode = 'verse'; - var tableColumns = 10; - var widthPercent = 10; - if (step.touchDevice) { - var ua = navigator.userAgent.toLowerCase(); - if ( (ua.indexOf("android") > -1) || - ((step.appleTouchDevice) && (ua.indexOf("safari/60") > -1)) ) { - tableColumns = 7; - widthPercent = 14; - } - } - var html = '
' + - '

' + headerMsg + '

'; - html += - '
' + - '
' + - '' + - ''; - for (var c = 0; c < tableColumns; c++) { - html += ''; - } - html += '' + - ''; - var chptrOrVrsNum = 0; - var previousVerseName = ""; - for (var i = 0; i < verses.length; i++) { - chptrOrVrsNum++; - var originalVerseName = verses[i]; - var verseName = step.copyText._shortenVerseName(previousVerseName, verses[i]); - previousVerseName = originalVerseName; - - if (firstSelection > -1) { - if (i == firstSelection) verseName = "" + verseName + ""; - html += '' - } - else html += '' - if ((chptrOrVrsNum > (tableColumns - 1)) && ((chptrOrVrsNum % tableColumns) == 0)) { - html += ''; - } - } - html += - '
' + verseName + - '' + verseName + - '
' + - '

'; - $('#bookchaptermodalbody').empty(); - $('#bookchaptermodalbody').append(html); } }; diff --git a/step-web/src/main/webapp/js/step.util.js b/step-web/src/main/webapp/js/step.util.js index 82cb71b78c..649718bb5a 100644 --- a/step-web/src/main/webapp/js/step.util.js +++ b/step-web/src/main/webapp/js/step.util.js @@ -819,6 +819,11 @@ step.util = { new PassageMenuView({ model: step.util.activePassage() }); + if (typeof PassageCopyMenuView === "function") { + new PassageCopyMenuView({ + model: step.util.activePassage() + }); + } Backbone.Events.trigger("columnsChanged", {}); return newPassageId; @@ -1821,6 +1826,109 @@ step.util = { return [verse, version]; }, + "getSelectionVerseInfo": function (el) { + var $el = $(el); + var verse = ''; + var version = ''; + + // Standard Bible:
> + verse = $($el.closest("div.verse").find('a.verseLink')[0]).attr('name'); + // Interleaved comparison:
+ if (!verse) verse = $el.closest(".verseGrouping").find(".heading .verseLink").attr("name"); + // Interlinear: + if (!verse) verse = $el.closest(".verse, .interlinear").find(".verseLink").attr("name"); + // Commentary: > + if (!verse) { + var commentaryVerse = $el.closest(".commentaryVerse"); + if (commentaryVerse.length > 0) + verse = commentaryVerse.find('a[name]').first().attr('name'); + } + + if (!verse) verse = ''; + // Handle space-separated OSIS IDs (e.g., "Gen.1.1 Gen.1.2") + if (verse.indexOf(' ') > -1) verse = verse.split(' ')[0]; + + // Version detection (same logic as getVerseNumberAndVersion) + version = $el.closest("div.verse").parent().find('span.smallResultKey').attr('data-version') || + $el.closest(".singleVerse").find('span.smallResultKey').attr('data-version'); + if (!version) { + var compareVersionHeader = $('th.comparingVersionName'); + if (compareVersionHeader.length > 0) { + var index = $el.closest('td').index(); + if (typeof index === 'number' && index > 0) + version = $(compareVersionHeader[index - 1]).text(); + } + } + // Fallback: active passage's masterVersion + if (!version || typeof step.keyedVersions[version] !== "object") { + var passageContainer = $el.closest('.passageContainer'); + if (passageContainer.length > 0) { + var passageId = passageContainer.attr('passage-id'); + var model = step.passages.findWhere({ passageId: parseInt(passageId) }); + if (model) version = model.get('masterVersion'); + } + } + if (!version) version = ''; + return { verse: verse, version: version }; + }, + + "initSelectionTracking": function () { + var debounceTimer; + step.lastPassageSelection = null; + document.addEventListener('selectionchange', function () { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(function () { + var sel = window.getSelection(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { + // Copy-dropdown gate: when a dropdown is open, clicks inside + // it collapse the native selection. Don't flip deselectedAt, + // or the 5s grace window will drop us out of selection mode. + if (step.copyDropdown && step.copyDropdown.shouldSuppressCollapseEvent && step.copyDropdown.shouldSuppressCollapseEvent()) + return; + if (step.lastPassageSelection && !step.lastPassageSelection.deselectedAt) + step.lastPassageSelection.deselectedAt = Date.now(); + return; + } + var range = sel.getRangeAt(0); + var startNode = range.startContainer; + var endNode = range.endContainer; + var startEl = (startNode.nodeType === 3) ? startNode.parentElement : startNode; + var endEl = (endNode.nodeType === 3) ? endNode.parentElement : endNode; + if (!startEl || $(startEl).closest('.passageContentHolder').length === 0) return; + var text = sel.toString().trim(); + if (text.length === 0) return; + var startInfo = step.util.ui["getSelectionVerseInfo"](startEl); + var endInfo = step.util.ui["getSelectionVerseInfo"](endEl); + var allVersions = []; + var addVersionIfNeeded = function (version) { + if (version && allVersions.indexOf(version) === -1) + allVersions.push(version); + }; + var rangeCommonAncestor = range.commonAncestorContainer; + if (rangeCommonAncestor && rangeCommonAncestor.nodeType === 3) + rangeCommonAncestor = rangeCommonAncestor.parentElement; + if (rangeCommonAncestor) { + $(rangeCommonAncestor).find('.verse, .singleVerse, .interlinear, .commentaryVerse').each(function () { + if (range.intersectsNode && !range.intersectsNode(this)) return; + var verseInfo = step.util.ui["getSelectionVerseInfo"](this); + addVersionIfNeeded(verseInfo.version); + }); + } + addVersionIfNeeded(startInfo.version); + addVersionIfNeeded(endInfo.version); + step.lastPassageSelection = { + startVerse: startInfo.verse, + endVerse: endInfo.verse, + version: startInfo.version || endInfo.version, + versions: allVersions, + textLength: text.length, + timestamp: Date.now(), + deselectedAt: null + }; + }, 150); + }); + }, + getWordOrderSuffix: function (el, strongsSelectedByUser) { var verseClass = $(el).closest('.verse'); if (verseClass.length == 0) @@ -2492,45 +2600,14 @@ step.util = { step.util.blockBackgroundScrolling("passageSelectionModal"); }, - copyModal: function () { // Do not shorten name in pom.xml because it is called at start.jsp - var element = document.getElementById('copyModal'); - if (element) element.parentNode.removeChild(element); - $("div.modal-backdrop.in").remove(); - var modalHTML = ''; - $(_.template(modalHTML)()).modal("show"); - step.util.blockBackgroundScrolling("copyModal"); - }, + copyModal: function () { // Do not shorten name in pom.xml — still called from start.jsp hrefs + // Legacy entry point (navbar #copy-icon). Forwards to the active panel's + // copy dropdown. Kept for backward compatibility with any bookmarked / + // external JS that invokes step.util.copyModal(). + var activePanelId = step.util.activePassageId(); + var $toggle = step.util.getPassageContainer(activePanelId).find(".copyDropdownToggle"); + if ($toggle.length) $toggle[0].click(); // our view's click handler catches this + }, lexFeedbackModal: function (strongNum, ref, version) { var element = document.getElementById('lexFeedbackModal'); @@ -4213,7 +4290,7 @@ step.util = { 'left', 499, 'step.multiVersionCount'); }, closeModal: function (modalID) { - var modalsRequireUnfreezeOfScroll = " showLongAlertModal showBookOrChapterSummaryModal grammarClrModal passageSelectionModal searchSelectionModal copyModal videoModal fontSettings raiseSupport aboutModal bibleVersions "; + var modalsRequireUnfreezeOfScroll = " showLongAlertModal showBookOrChapterSummaryModal grammarClrModal passageSelectionModal searchSelectionModal videoModal fontSettings raiseSupport aboutModal bibleVersions "; if ((modalsRequireUnfreezeOfScroll.indexOf( " " + modalID + " ") > -1) && step.touchDevice && !step.touchWideDevice) $("body").css("overflow-y","auto"); // let the body (web page) scroll if (modalID === "bibleVersions") diff --git a/step-web/src/main/webapp/js/step_ready.js b/step-web/src/main/webapp/js/step_ready.js index ec8d6f616b..e04e1bf010 100644 --- a/step-web/src/main/webapp/js/step_ready.js +++ b/step-web/src/main/webapp/js/step_ready.js @@ -404,6 +404,11 @@ new PassageMenuView({ model: modelZero }); + if (typeof PassageCopyMenuView === "function") { + new PassageCopyMenuView({ + model: modelZero + }); + } step.router.handleRenderModel(modelZero, true, $.getUrlVar('q')); @@ -495,6 +500,10 @@ initCoreModelsAndRouter(); initSearchDropdown(); initReportDropdownPortal(); + if (!step.selectionTrackingInitialized) { + step.selectionTrackingInitialized = true; + step.util.ui.initSelectionTracking(); + } Backbone.history.start({pushState: true, silent: true}); @@ -617,7 +626,7 @@ var ua = navigator.userAgent.toLowerCase(); if (ua.indexOf('firefox') > -1) $("#panel-icon").hide(); // Firefox has some issues with this. if ((typeof navigator.clipboard !== "object") || (typeof navigator.clipboard.writeText !== "function")) - $("#copy-icon").hide(); + { $("#copy-icon").hide(); $(".copyDropdown").hide(); } if ( ((step.appleTouchDevice) && (ua.search(/ cpu os [345678]_/) > -1)) || // older versions of iOS shows a light grey color. Probably similiar issue as Internet Explorer (false || !!document.documentMode) ) { // Internet Explorer use the wrong css based on the tag, so change it to black $("#panel-icon").css("color", "black"); diff --git a/step-web/src/main/webapp/scss/copy_dropdown.scss b/step-web/src/main/webapp/scss/copy_dropdown.scss new file mode 100644 index 0000000000..236ba16976 --- /dev/null +++ b/step-web/src/main/webapp/scss/copy_dropdown.scss @@ -0,0 +1,323 @@ +@import 'template_variables'; + +.copyDropdown { + display: inline-block; + + .copyDropdownToggle { + cursor: pointer; + color: $text-color; + + &:hover, + &:focus { + text-decoration: none; + background-color: $secondaryHoverColour; + color: black; + } + } + + .copyMenu { + min-width: 280px; + max-width: 420px; + padding: 8px; + font-size: 14px; + + .copyMenuHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; + + h2 { + font-size: 14px; + margin: 0; + } + + .copyCloseBtn { + background: transparent; + border: 0; + font-size: 18px; + line-height: 1; + padding: 2px 6px; + cursor: pointer; + color: inherit; + + &:hover, + &:focus { + background-color: $secondaryHoverColour; + outline: none; + } + + &[disabled] { + opacity: 0.4; + cursor: not-allowed; + } + } + } + + .copyStatusRow { + margin: 4px 0 8px; + padding: 6px 8px; + border-radius: 4px; + font-size: 13px; + background-color: var(--clr2ndHover); + + &:empty { + display: none; + } + + &.copyStatus--success { + background-color: #dff0d8; + color: #3c763d; + } + + &.copyStatus--error, + &.copyStatus--clipboard-denied, + &.copyStatus--copy-error, + &.copyStatus--ajax-error, + &.copyStatus--stale-passage { + background-color: #f2dede; + color: #a94442; + } + + &.copyStatus--cooldown, + &.copyStatus--rapid-warning { + background-color: #fcf8e3; + color: #8a6d3b; + } + + &.copyStatus--unresolved, + &.copyStatus--no-versions { + background-color: #d9edf7; + color: #31708f; + } + } + + .copySelectionRow { + margin: 4px 0 12px; + + .copyPickDifferent { + background: transparent; + border: 0; + color: $text-color; + cursor: pointer; + padding: 6px 0 0; + font-size: 12px; + text-decoration: underline; + + &:hover, + &:focus { + color: black; + } + } + } + + .copyOptionsStrip { + margin: 6px 0; + + .copyVersions { + border: 0; + margin: 0 0 6px; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + + legend { + border: 0; + margin: 0 0 4px; + padding: 0; + font-size: 12px; + font-weight: 600; + width: 100%; + } + + label { + font-weight: normal; + margin: 0; + white-space: nowrap; + } + + input[type="checkbox"] { + margin-right: 4px; + vertical-align: middle; + } + } + + .copyToggleRow { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + + label { + font-weight: normal; + margin: 0; + white-space: nowrap; + + input[type="checkbox"] { + margin-right: 4px; + vertical-align: middle; + } + } + } + } + + .copyMenuFooter { + margin-top: 6px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + .copyPrimaryBtn { + display: inline-block; + padding: 6px 12px; + border: 1px solid $border-color; + border-radius: 4px; + background-color: var(--clrBackground); + color: $text-color; + cursor: pointer; + font-size: 13px; + font-weight: 600; + + &:hover:not([disabled]), + &:focus:not([disabled]) { + background-color: $secondaryHoverColour; + color: black; + outline: none; + } + + &[disabled], + &.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } + } + + .copySelectionPrimary { + width: 100%; + text-align: center; + } + + // Grid-mode footer buttons + selection-mode primary inherit the + // .copyPrimaryBtn boxed look (border, radius, padding, hover) — only + // the resting color is overridden to match the .argSelect panel-bar + // buttons (bible / reference / search). Hover (.copyPrimaryBtn:hover) + // flips to black, disabled (.copyPrimaryBtn[disabled]) fades via + // opacity. Same SCSS specificity (0,3,0); placed after .copyPrimaryBtn + // in source order. + .copySelectionPrimary, + .copyGridPrimary, + .copyBackToSelection { + color: $strong-color; + color: var(--clrStrongText); + } + + .copyGridSection { + margin-top: 6px; + + .copyGridChapterLabel { + font-size: 12px; + font-weight: 600; + padding: 4px 0; + position: sticky; + top: 0; + background-color: var(--clrBackground); + z-index: 1; + } + + .copyGridScroller { + overflow-y: auto; + max-height: 380px; + border: 1px solid $border-color; + border-radius: 3px; + } + + .copyGrid { + width: 100%; + border-collapse: collapse; + + td { + padding: 0; + text-align: center; + border: 1px solid rgba(0, 0, 0, 0.05); + } + } + + .copyGridCell { + display: block; + width: 100%; + padding: 5px 2px; + cursor: pointer; + color: $text-color; + font-size: 13px; + touch-action: manipulation; + background: transparent; + border: 0; + border-radius: 2px; + text-align: center; + font-family: inherit; + line-height: 1.2; + + &:hover, + &:focus { + background-color: $secondaryHoverColour; + color: black; + outline: none; + } + + &[data-role="start"], + &[data-role="end"] { + background-color: #337ab7; + color: white; + font-weight: 600; + } + + &[data-role="in-range"] { + background-color: #d9edf7; + color: #31708f; + } + + &[aria-disabled="true"], + &.copyGridCell--gap { + opacity: 0.3; + cursor: not-allowed; + pointer-events: none; + } + } + } + } + + @media (max-width: 640px) { + .copyMenu { + position: fixed; + left: 8px; + right: 8px; + bottom: 8px; + top: auto; + max-width: none; + max-height: 70vh; + overflow-y: auto; + } + } + + @media (prefers-reduced-motion: reduce) { + .copyMenu, + .copyGridCell { + transition: none !important; + animation: none !important; + } + } +} + +[dir='rtl'] .copyDropdown .copyMenu { + text-align: right; +} + +@media print { + .copyDropdown { + display: none !important; + } +} diff --git a/step-web/src/main/webapp/scss/modals.scss b/step-web/src/main/webapp/scss/modals.scss index 8328cdf80e..402c6cc711 100644 --- a/step-web/src/main/webapp/scss/modals.scss +++ b/step-web/src/main/webapp/scss/modals.scss @@ -350,5 +350,35 @@ } } +.copySelectionOnly { + max-width: 560px; + margin: 0 auto; + padding: 16px 8px; + text-align: center; + h4 { margin-bottom: 12px; } +} +.copySelectionRange { + font-size: 1.1em; + font-weight: 500; + margin-bottom: 20px; +} +.copySelectionPrimary { + min-height: 44px; + min-width: 180px; + padding: 10px 24px; + font-size: 1.05em; +} +.copySelectionEscape { + margin-top: 18px; + font-size: 0.9em; +} +.copySelectionFallback { + padding: 10px 14px; + margin-bottom: 12px; + background: rgba(255, 200, 0, 0.15); + border-left: 3px solid #c90; + font-size: 0.95em; +} + @import 'bible_versions'; @import 'advanced_search'; diff --git a/step-web/src/main/webapp/scss/passage_columns.scss b/step-web/src/main/webapp/scss/passage_columns.scss index aec2d04e65..d07cc1a398 100644 --- a/step-web/src/main/webapp/scss/passage_columns.scss +++ b/step-web/src/main/webapp/scss/passage_columns.scss @@ -2,6 +2,7 @@ @import 'mixins'; @import 'arg_summary'; @import 'passage_options'; +@import 'copy_dropdown'; @import 'passage_display'; @import 'search_display'; @import 'cross_references'; diff --git a/step-web/src/main/webapp/start.jsp b/step-web/src/main/webapp/start.jsp index 6000bfe4ce..5eef80affb 100644 --- a/step-web/src/main/webapp/start.jsp +++ b/step-web/src/main/webapp/start.jsp @@ -235,6 +235,7 @@ + <% @@ -271,7 +272,7 @@ - "> + @@ -445,6 +446,14 @@ + + + "> @@ -727,6 +736,7 @@ userCountry = (userCountry == null) ? "UNKNOWN" : userCountry.toUpperCase(); + From 493d512c49de8a5897a6c4dd0f821cd6b949add7 Mon Sep 17 00:00:00 2001 From: vercaemert Date: Sat, 16 May 2026 09:06:13 +0000 Subject: [PATCH 2/8] Inline green-success banner for per-panel copy dropdown Replace the top status row with a dedicated inline banner so the "text is copied" confirmation appears where the user is looking: - Selection mode: below the options strip (.copyBottomSuccess) - Grid mode: in the footer to the right of Copy, or filling the gap between Copy and Back-to-selection when present (.copyFooterSuccess) Banner is cleared on dropdown close, on _update, and defensively when _onRapidWarning fires so a stale green can't coexist with the cooldown warning at the top. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../js/backbone/views/view_menu_copy.js | 30 +++++++++++++++++-- .../src/main/webapp/scss/copy_dropdown.scss | 27 +++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/step-web/src/main/webapp/js/backbone/views/view_menu_copy.js b/step-web/src/main/webapp/js/backbone/views/view_menu_copy.js index 6a38a62945..795550cc83 100644 --- a/step-web/src/main/webapp/js/backbone/views/view_menu_copy.js +++ b/step-web/src/main/webapp/js/backbone/views/view_menu_copy.js @@ -168,6 +168,8 @@ var PassageCopyMenuView = Backbone.View.extend({ $dd.removeClass("open"); $dd.find(".copyDropdownToggle").attr("aria-expanded", "false"); this._unbindOutsideClick(); + this._clearInlineSuccess(); + if (this._statusTimer) { clearTimeout(this._statusTimer); this._statusTimer = null; } step.copyDropdown.release(this.panelId); }, @@ -238,6 +240,7 @@ var PassageCopyMenuView = Backbone.View.extend({ '' + '' + '' + + '
' + '