diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue index 1923e8cdc7..09755c3d41 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue @@ -183,12 +183,11 @@ }; onMounted(() => { - // capture - document.addEventListener('click', handleClickOutside, true); + document.addEventListener('click', handleClickOutside); }); onUnmounted(() => { - document.removeEventListener('click', handleClickOutside, true); + document.removeEventListener('click', handleClickOutside); }); const getMarkdownContent = () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue index 0abd2b3f7c..df6732dff6 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue @@ -4,11 +4,16 @@ ref="toolbarRef" class="toolbar" role="toolbar" + :style="{ + backgroundColor: $themePalette.grey.v_50, + borderBottom: `1px solid ${$themeTokens.fineLine}`, + }" :aria-label="textFormattingToolbar$()" >
@@ -34,308 +40,93 @@ - -
+ - -
- - - - -
- - -
- - - - - - - - - - - - - - - -
- -
- - - - -
- -
- - - - -
- -
- - -
- - - + + + + + - - - -
-
+ + + + + + - import { defineComponent, ref, computed, onMounted, onUnmounted, nextTick } from 'vue'; + import { ref, computed } from 'vue'; import { useToolbarActions } from '../composables/useToolbarActions'; import { getTipTapEditorStrings } from '../TipTapEditorStrings'; import { useDropdowns } from '../composables/useDropdowns'; @@ -356,7 +147,7 @@ import PasteDropdown from './toolbar/PasteDropdown.vue'; import ToolbarDivider from './toolbar/ToolbarDivider.vue'; - export default defineComponent({ + export default { name: 'EditorToolbar', components: { ToolbarButton, @@ -366,49 +157,18 @@ }, setup(props, { emit }) { const toolbarRef = ref(null); - const moreButton = ref(null); - const moreDropdown = ref(null); - const moreDropdownContainer = ref(null); - const isMoreDropdownOpen = ref(false); - const toolbarWidth = ref(0); - - // TODO: Maybe these shouldnt be hardcoded? - const OVERFLOW_BREAKPOINTS = { - insert: 760, - script: 710, - lists: 650, - clearFormat: 560, - align: 530, - clipboard: 500, - textFormat: 400, - }; - - // Categories that can overflow (in order of overflow priority) - const OVERFLOW_CATEGORIES = [ - 'insert', - // Perseus flavoured markdown does not support super and sub script, - // so we disable this for now until we stop using markdown as the primary target - // 'script', - 'lists', - 'clearFormat', - // Perseus flavoured markdown does not support alignment, - // so we disable this for now until we stop using markdown as the primary target - // 'align', - 'clipboard', - 'textFormat', - ]; const { handleCopy, handleClearFormat, canClearFormat, historyActions, - textActions, alignAction, + textActions, listActions, - scriptActions, insertTools, minimizeAction, + scriptActions, } = useToolbarActions(emit); const { pasteOptions } = useDropdowns(); @@ -427,49 +187,8 @@ moreButtonText$, } = getTipTapEditorStrings(); - // Compute which categories should be visible vs in overflow - const visibleCategories = computed(() => { - return OVERFLOW_CATEGORIES.filter( - category => toolbarWidth.value >= OVERFLOW_BREAKPOINTS[category], - ); - }); - - const overflowCategories = computed(() => { - return OVERFLOW_CATEGORIES.filter(category => !visibleCategories.value.includes(category)); - }); - - // Handle resize observer - let resizeObserver = null; - - const updateToolbarWidth = () => { - if (toolbarRef.value) { - // Batch layout reads in next frame - requestAnimationFrame(() => { - toolbarWidth.value = toolbarRef.value.offsetWidth; - }); - } - }; - - const handleResize = entries => { - // Use ResizeObserver data directly - no DOM reading - requestAnimationFrame(() => { - for (const entry of entries) { - toolbarWidth.value = entry.contentRect.width; - } - }); - }; - - const handleWindowResize = () => { - requestAnimationFrame(updateToolbarWidth); - }; - - const onToolClick = (tool, event) => { - isMoreDropdownOpen.value = false; - let target = event.currentTarget; - - // If the tool is in the overflow menu, we center the modal - if (!visibleCategories.value.includes('insert')) target = null; - + const onInsertToolClick = (tool, event, { fromOverflow } = {}) => { + const target = fromOverflow ? null : event.currentTarget; if (tool.name === 'image') { emit('insert-image', target); } else if (tool.name === 'link') { @@ -477,117 +196,166 @@ } else if (tool.name === 'math') { emit('insert-math', target); } else { - // For all other buttons, call their original handler tool.handler(); } }; - const toggleMoreDropdown = () => { - isMoreDropdownOpen.value = !isMoreDropdownOpen.value; - }; + // Toolbar groups in visual order (left-to-right). + // KListWithOverflow will collapse items from the end first. + // Note: Don't include dividers here - this will be the source of truth. + + // Each group describes its actions via `groupActions`. A `label` makes + // the wrapper a role="group"; omitting it renders a plain div (for + // single-action groups that don't need grouping semantics). + // Actions with a `component` key render that component instead of a + // ToolbarButton (e.g. PasteDropdown). + const toolbarGroups = computed(() => [ + { + name: 'textFormat', + role: 'group', + label: textStyleFormatting$(), + groupActions: textActions.value, + }, + { + name: 'clipboard', + role: 'group', + label: copyAndPasteActions$(), + groupActions: [ + { + name: 'copy', + title: copy$(), + icon: require('../../assets/icon-copy.svg'), + handler: handleCopy, + }, + { + name: 'pasteDropdown', + component: PasteDropdown, + dropdownActions: pasteOptions.value, + }, + ], + }, + { + name: 'align', + groupActions: [alignAction.value], + // Perseus flavoured markdown does not support alignment, + // so we disable this for now until we stop using markdown as the primary target + hide: true, + }, + { + name: 'clearFormat', + groupActions: [ + { + name: 'clearFormat', + title: clearFormatting$(), + icon: require('../../assets/icon-clearFormat.svg'), + isAvailable: canClearFormat.value, + handler: handleClearFormat, + }, + ], + }, + { + name: 'lists', + role: 'group', + label: listFormatting$(), + groupActions: listActions.value, + }, + { + name: 'script', + role: 'group', + label: scriptFormatting$(), + groupActions: scriptActions.value, + // Perseus flavoured markdown does not support super and sub script, + // so we disable this for now until we stop using markdown as the primary target + hide: true, + }, + { + name: 'insert', + role: 'group', + label: insertTools$(), + groupActions: insertTools.value.map(tool => ({ + ...tool, + handler: (e, { fromOverflow } = {}) => onInsertToolClick(tool, e, { fromOverflow }), + })), + }, + ]); + + // Flattens the visible overflow groups into a KDropdownMenu-compatible + // options array. Maps `title` → `label` and `isAvailable` → `disabled`. + // Actions with `dropdownActions` (e.g. PasteDropdown) are expanded into + // their constituent items; other component-only actions are skipped. + const flatOverflowOptions = overflowItems => { + const options = []; + const toOption = a => ({ + ...a, + label: a.title, + disabled: a.isAvailable !== undefined ? !a.isAvailable : false, + }); - // Handle keyboard navigation in dropdown menu - const handleMenuKeydown = async event => { - if (event.key === 'Escape') { - isMoreDropdownOpen.value = false; - // Return focus to the more button - await nextTick(); - moreButton.value?.$el?.focus(); - } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - event.preventDefault(); - const menuItems = Array.from( - moreDropdown.value?.querySelectorAll('[role="menuitem"]:not(:disabled)') || [], - ); - const currentIndex = menuItems.indexOf(document.activeElement); - - let nextIndex; - if (event.key === 'ArrowDown') { - nextIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0; - } else { - nextIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1; + for (const group of overflowItems) { + if (group.type === 'divider') { + options.push(group); + continue; + } + for (const action of group.groupActions) { + if (action.dropdownActions) { + for (const dropdownAction of action.dropdownActions) { + options.push(toOption(dropdownAction)); + } + } else { + options.push(toOption(action)); + } } - - menuItems[nextIndex]?.focus(); } - }; - // Close dropdown when clicking outside - const handleClickOutside = event => { - if (moreDropdownContainer.value && !moreDropdownContainer.value.contains(event.target)) { - isMoreDropdownOpen.value = false; - } + return options; }; - onMounted(async () => { - await nextTick(); - - // Initial width measurement in next frame - requestAnimationFrame(() => { - updateToolbarWidth(); + /** + * Place a divider element between toolbar groups in the overflow menu. + * This is necessary to tell KListWithOverflow where to render dividers + * between groups. + */ + const toolbarGroupsWithDividers = computed(() => { + const groups = []; + toolbarGroups.value.forEach((group, index) => { + if (group.hide) { + return; + } + groups.push(group); + if (index < toolbarGroups.value.length - 1) { + groups.push({ type: 'divider' }); + } }); - - // Set up resize observer - if (toolbarRef.value && window.ResizeObserver) { - resizeObserver = new ResizeObserver(handleResize); - resizeObserver.observe(toolbarRef.value); - } else { - // Fallback to window resize listener with passive flag - window.addEventListener('resize', handleWindowResize, { passive: true }); - } - - document.addEventListener('mousedown', handleClickOutside, { passive: true }); + return groups; }); - onUnmounted(() => { - if (resizeObserver) { - resizeObserver.disconnect(); - } else { - window.removeEventListener('resize', handleWindowResize); - } - document.removeEventListener('mousedown', handleClickOutside); - }); + const onOverflowSelect = (option, event) => { + // Stop propagation to avoid triggering RTE on outside click handler that + // minimizes the editor because KDropdownMenu is attached to an overlay layer, + // i.e. not a descendant of the editor. + event.stopPropagation(); + option.handler(event, { fromOverflow: true }); + }; return { toolbarRef, - moreButton, - moreDropdown, - moreDropdownContainer, - isMoreDropdownOpen, - visibleCategories, - overflowCategories, - handleCopy, - handleClearFormat, - onToolClick, - toggleMoreDropdown, - handleMenuKeydown, - canClearFormat, + toolbarGroupsWithDividers, + flatOverflowOptions, historyActions, - textActions, - alignAction, - listActions, - scriptActions, - insertTools, minimizeAction, - pasteOptions, - copy$, + onOverflowSelect, textFormattingToolbar$, historyActions$, textFormattingOptions$, - textStyleFormatting$, - copyAndPasteActions$, - listFormatting$, - scriptFormatting$, - insertTools$, - clearFormatting$, moreButtonText$, }; }, - }); + }; - diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/FormatDropdown.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/FormatDropdown.vue index 7d5b2a4a1f..97c1e676b5 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/FormatDropdown.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/FormatDropdown.vue @@ -258,13 +258,6 @@ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } - @media screen and (max-width: 600px) { - .format-dropdown { - width: 80px; - min-width: 0; - } - } - .dropdown-item { display: flex; gap: 8px; @@ -277,7 +270,6 @@ 'Segoe UI', sans-serif; font-size: 14px; - line-height: 140%; color: #000000; cursor: pointer; } diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js index 27215c97bd..ea53b89f77 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js @@ -383,6 +383,7 @@ export function useToolbarActions(emit) { icon: require('../../assets/icon-bulletList.svg'), handler: handleBulletList, isActive: isMarkActive('bulletList'), + shouldFlipInRtl: true, }, { name: 'numberList', diff --git a/package.json b/package.json index cb6196795f..96ce55e224 100644 --- a/package.json +++ b/package.json @@ -198,6 +198,9 @@ "node-sass", "unrs-resolver", "vue-demi" - ] + ], + "overrides": { + "kolibri-design-system": "github:nucleogenesis/kolibri-design-system-1#studio/5258-rte--dynamic-width/klistwithoverflow-wrap-fix" + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d04c245978..5fa2cc3986 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + kolibri-design-system: github:nucleogenesis/kolibri-design-system-1#studio/5258-rte--dynamic-width/klistwithoverflow-wrap-fix + importers: .: @@ -84,8 +87,8 @@ importers: specifier: ^0.2.12 version: 0.2.12 kolibri-design-system: - specifier: 5.6.0 - version: 5.6.0 + specifier: github:nucleogenesis/kolibri-design-system-1#studio/5258-rte--dynamic-width/klistwithoverflow-wrap-fix + version: https://codeload.github.com/nucleogenesis/kolibri-design-system-1/tar.gz/f7e2093f448029ed891c5c8a13170c26d9b06ae4 lodash: specifier: ^4.18.1 version: 4.18.1 @@ -5124,8 +5127,9 @@ packages: kolibri-constants@0.2.12: resolution: {integrity: sha512-ApVc/KLwEaDJohqKhQTdao4UdWmoyq2pQ5lk8ra+1rDpJvsFWsAOGVC4RTv4YEDlAYJzj2/QZlJQ91u5yUURSQ==} - kolibri-design-system@5.6.0: - resolution: {integrity: sha512-lKCQVnnjguUQUIzQ5rKkUyNRWOIhY4cOOtPmuW0H3gz+hv73wPvBjbI2u9kQqA+oFs/C2MsL60roiMMbQDmt4A==} + kolibri-design-system@https://codeload.github.com/nucleogenesis/kolibri-design-system-1/tar.gz/f7e2093f448029ed891c5c8a13170c26d9b06ae4: + resolution: {tarball: https://codeload.github.com/nucleogenesis/kolibri-design-system-1/tar.gz/f7e2093f448029ed891c5c8a13170c26d9b06ae4} + version: 5.6.2 kolibri-format@1.0.1: resolution: {integrity: sha512-yGQpsJkBAzmRueAq6MG1UOuDl9pbhEtMWNxq9ObG5pPVkG8uhWJAS1L71GCuNAeaV1XG2IWo2565Ov4yXnudeA==} @@ -13825,7 +13829,7 @@ snapshots: kolibri-constants@0.2.12: {} - kolibri-design-system@5.6.0: + kolibri-design-system@https://codeload.github.com/nucleogenesis/kolibri-design-system-1/tar.gz/f7e2093f448029ed891c5c8a13170c26d9b06ae4: dependencies: aphrodite: https://codeload.github.com/learningequality/aphrodite/tar.gz/fdc8d7be8912a5cf17f74ff10f124013c52c3e32 autosize: 3.0.21