diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue index 882965a59d..61ba141e52 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue @@ -11,7 +11,7 @@ :tabindex="tabindex" role="textbox" :aria-label="editorMode === 'edit' ? TipTapEditorLabel$() : TipTapViewerLabel$()" - aria-multiline="true" + :aria-multiline="String(!inlineOnly)" @keydown="handleContainerKeydown" @focusin="hasFocusWithin = true" @focusout="handleFocusout" @@ -96,6 +96,7 @@ import FormulasMenu from './components/math/FormulasMenu.vue'; import { preprocessMarkdown } from './utils/markdown'; import { resolveImageSrcs, toStoredImageSrcs } from './utils/imageSrc'; + import { toInlineHTML } from './utils/inlineContent'; import MobileTopBar from './components/toolbar/MobileTopBar.vue'; import MobileFormattingBar from './components/toolbar/MobileFormattingBar.vue'; import { getTipTapEditorStrings } from './TipTapEditorStrings'; @@ -118,6 +119,11 @@ provide('isReady', isReady); provide('insertContext', insertContext); provide('insertActions', toRef(props, 'insertActions')); + // Read once: the schema is fixed when the editor is created. + const { inlineOnly } = props; + provide('inlineOnly', inlineOnly); + // The markdown serializer writes each inline child of an inline-only doc as a block. + const isHTML = computed(() => props.format === 'html' || inlineOnly); // The anchored modals are measured and hit-tested through these refs, so that several // editors mounted at once each work with their own modal. @@ -150,7 +156,8 @@ const handleDrop = event => { const file = event.dataTransfer.files[0]; - if (file) { + // An inline-only schema has no image node to insert. + if (file && !inlineOnly) { imageHandler.openCreateModal({ file }); } }; @@ -160,7 +167,7 @@ // Image srcs are resolved for display on the way in, so they are reduced // back to their stored form here — leaving this the one place that reads // content out, whichever form the editor happens to be holding. - if (props.format === 'html') return toStoredImageSrcs(editor.value.getHTML()); + if (isHTML.value) return toStoredImageSrcs(editor.value.getHTML()); if (!editor.value.storage?.markdown) return ''; return editor.value.storage.markdown.getMarkdown(); }; @@ -195,13 +202,16 @@ return; } - const processedContent = - props.format === 'html' ? resolveImageSrcs(newValue) : preprocessMarkdown(newValue); + let processedContent; + if (inlineOnly) processedContent = toInlineHTML(newValue); + else if (isHTML.value) processedContent = resolveImageSrcs(newValue); + else processedContent = preprocessMarkdown(newValue); if (!editor.value) { initializeEditor(processedContent, props.mode, { autofocus: props.autofocus, extensions: props.extensions, + inlineOnly, }); return; } @@ -308,6 +318,15 @@ type: Array, default: () => [], }, + /** + * Holds one line of inline content; blocks and line breaks in the value become + * spaces. Read once, when the editor is created. Reads and writes HTML, whatever + * `format` says. + */ + inlineOnly: { + type: Boolean, + default: false, + }, /** * Actions appended to the insert tools of every toolbar. Each is * `{ name, title, icon, handler, isActive?, isAvailable?, prominent? }`: diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/__mocks__/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/__mocks__/TipTapEditor.vue index f982936da9..146f214158 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/__mocks__/TipTapEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/__mocks__/TipTapEditor.vue @@ -29,12 +29,14 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'; import { useEditor } from '../composables/useEditor'; import { resolveInsertAction } from '../composables/useToolbarActions'; + import { toInlineHTML } from '../utils/inlineContent'; export default { name: 'RichTextEditor', setup(props, { emit, listeners }) { const input = ref(null); const { editor, isReady, insertContext, initializeEditor } = useEditor(); + const content = value => (props.inlineOnly ? toInlineHTML(value) : value); // A real editor, so `ready` carries an instance whose schema holds the // consumer's extensions and whose commands run. Built only for a test that @@ -43,7 +45,10 @@ () => Boolean(props.extensions.length || props.insertActions.length || listeners.ready), needed => { if (needed && !editor.value) { - initializeEditor(props.value, props.mode, { extensions: props.extensions }); + initializeEditor(content(props.value), props.mode, { + extensions: props.extensions, + inlineOnly: props.inlineOnly, + }); } }, { immediate: true }, @@ -55,7 +60,7 @@ watch( () => props.value, - value => editor.value?.commands.setContent(value || '

'), + value => editor.value?.commands.setContent(content(value) || '

'), ); // The textarea stands in for the editor's view, so focusing it places the @@ -106,6 +111,10 @@ type: Array, default: () => [], }, + inlineOnly: { + type: Boolean, + default: false, + }, }, emits: ['update', 'ready'], }; 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 b1ec84096a..ae0b1a98e7 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/EditorToolbar.vue @@ -30,15 +30,17 @@ -
- -
- - + - import { ref, computed } from 'vue'; + import { ref, computed, inject } from 'vue'; import { useToolbarActions } from '../composables/useToolbarActions'; import { getTipTapEditorStrings } from '../TipTapEditorStrings'; import { useDropdowns } from '../composables/useDropdowns'; @@ -183,13 +185,14 @@ setup(props, { emit }) { const toolbarRef = ref(null); useRovingTabIndex(toolbarRef); + const inlineOnly = inject('inlineOnly', false); const { handleCopy, handleClearFormat, canClearFormat, historyActions, - alignAction, + alignActions, textActions, listActions, insertTools, @@ -262,7 +265,7 @@ }, { name: 'align', - groupActions: [alignAction.value], + groupActions: alignActions.value, }, { name: 'clearFormat', @@ -342,9 +345,10 @@ */ const toolbarGroupsWithDividers = computed(() => { const groups = []; - toolbarGroups.value.forEach((group, index) => { + const nonEmptyGroups = toolbarGroups.value.filter(group => group.groupActions.length); + nonEmptyGroups.forEach((group, index) => { groups.push(group); - if (index < toolbarGroups.value.length - 1) { + if (index < nonEmptyGroups.length - 1) { groups.push({ type: 'divider' }); } }); @@ -357,6 +361,7 @@ return { toolbarRef, + inlineOnly, toolbarGroupsWithDividers, flatOverflowOptions, historyActions, diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/MobileFormattingBar.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/MobileFormattingBar.vue index 1c0e301e09..1d2c22c4ff 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/MobileFormattingBar.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/toolbar/MobileFormattingBar.vue @@ -35,67 +35,55 @@ @touchstart="event => event.stopPropagation()" @touchend="event => event.stopPropagation()" > -
- - +
- -
- - - - - - - - - + + + +
+ + + + + - import { defineComponent, ref, onMounted, onUnmounted, inject } from 'vue'; + import { defineComponent, ref, computed, onMounted, onUnmounted, inject } from 'vue'; import { useToolbarActions } from '../../composables/useToolbarActions'; import { useFormatControls } from '../../composables/useFormatControls'; import { getTipTapEditorStrings } from '../../TipTapEditorStrings'; @@ -138,6 +126,7 @@ const isExpanded = ref(true); const keyboardOffset = ref(0); const editor = inject('editor'); + const inlineOnly = inject('inlineOnly', false); const toolbarRef = ref(null); useRovingTabIndex(toolbarRef); @@ -151,9 +140,13 @@ textFormattingToolbar$, } = getTipTapEditorStrings(); - const { textActions, listActions, scriptActions, alignAction, insertTools } = + const { textActions, listActions, scriptActions, alignActions, insertTools } = useToolbarActions(emit); + const actionGroups = computed(() => + [textActions.value, listActions.value, alignActions.value].filter(group => group.length), + ); + const { canIncreaseFormat, canDecreaseFormat, increaseFormat, decreaseFormat } = useFormatControls(); @@ -217,12 +210,11 @@ return { isExpanded, + inlineOnly, keyboardOffset, toolbarRef, - textActions, - listActions, + actionGroups, scriptActions, - alignAction, insertTools, toggleToolbar, canIncreaseFormat, diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js index b358effb40..b57916c10f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js @@ -10,9 +10,26 @@ import { StyledStrike, StyledUnderline } from '../extensions/TextDecoration'; import { Image } from '../extensions/Image'; import { CodeBlockSyntaxHighlight } from '../extensions/CodeBlockSyntaxHighlight'; import { Math } from '../extensions/Math'; +import { InlineDocument } from '../extensions/InlineDocument'; import { createCustomMarkdownSerializer } from '../utils/markdownSerializer'; import { transformPastedHTML } from '../utils/pasteTransform'; +// Inline-only fields leave every block node and the line break out of the schema, +// so no command, shortcut or input rule has one to create. +const INLINE_ONLY_STARTER_KIT = { + document: false, + paragraph: false, + heading: false, + blockquote: false, + bulletList: false, + orderedList: false, + listItem: false, + listKeymap: false, + horizontalRule: false, + hardBreak: false, + trailingNode: false, +}; + // Whether replacing the selection would delete a line break, welding two lines. function spansLines({ doc, selection }) { if (selection.empty) return false; @@ -42,7 +59,7 @@ export function useEditor() { const initializeEditor = ( content, mode = 'edit', - { autofocus = false, extensions = [] } = {}, + { autofocus = false, extensions = [], inlineOnly = false } = {}, ) => { editor.value = new Editor({ autofocus, @@ -59,18 +76,23 @@ export function useEditor() { // decoration as a style on a — the QTI 3.0 HTML profile has no or . strike: false, underline: false, + ...(inlineOnly && INLINE_ONLY_STARTER_KIT), }), - CodeBlockSyntaxHighlight, - Small, + ...(inlineOnly + ? [InlineDocument] + : [ + CodeBlockSyntaxHighlight, + Small, + Image, + TextAlign.configure({ + types: ['heading', 'paragraph', 'image', 'small'], + }), + ]), StyledStrike, StyledUnderline, Superscript, Subscript, - Image, Math, - TextAlign.configure({ - types: ['heading', 'paragraph', 'image', 'small'], - }), ...extensions, ], content: content || '

', @@ -78,8 +100,30 @@ export function useEditor() { attributes: { class: 'prose prose-sm sm:prose lg:prose-lg xl:prose-2xl focus:outline-none', dir: 'auto', + ...(inlineOnly && { 'aria-multiline': 'false' }), }, - transformPastedHTML: html => transformPastedHTML(html), + transformPastedHTML: html => transformPastedHTML(html, { inlineOnly }), + // ProseMirror wraps each line of pasted plain text in a paragraph, which an + // inline-only schema joins to the next with no space between. + ...(inlineOnly && { + transformPastedText: text => { + if (!/[\r\n]/.test(text)) return text; + return text + .split(/[\r\n]+/) + .map(line => line.trim()) + .filter(Boolean) + .join(' '); + }, + // Android Chrome leaves Enter to the browser. Its newline, after Shift or + // beside a math node, reads back as a space that the keymap never sees. + handleDOMEvents: { + beforeinput: (view, event) => { + if (!['insertParagraph', 'insertLineBreak'].includes(event.inputType)) return false; + event.preventDefault(); + return true; + }, + }, + }), }, onCreate: () => { isReady.value = true; 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 ed5135f136..12a0b36d54 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useToolbarActions.js @@ -1,6 +1,5 @@ import { computed, inject } from 'vue'; import { getTipTapEditorStrings } from '../TipTapEditorStrings'; -import { transformPastedHTML } from '../utils/pasteTransform'; /** * Evaluates a contributed insert action against the editor's insert context. @@ -28,13 +27,16 @@ export function useToolbarActions(emit) { const editor = inject('editor', null); const insertContext = inject('insertContext', null); const contributedInsertActions = inject('insertActions', null); + const inlineOnly = inject('inlineOnly', false); /** - * Drop the actions marked `hide`, which every toolbar honours — the desktop one and - * the mobile bars alike. The action stays defined, with the reason it is not offered, - * so restoring it is a matter of deleting one flag. + * Drop the actions marked `hide`, and in an inline-only editor those marked `blockOnly`. + * Every toolbar honours this — the desktop one and the mobile bars alike. A `hide` action + * stays defined, with the reason it is not offered, so restoring it is a matter of + * deleting one flag. */ - const visible = actions => actions.filter(action => !action.hide); + const visible = actions => + actions.filter(action => !action.hide && !(inlineOnly && action.blockOnly)); // helper const getEffectiveAlignment = editorInstance => { @@ -185,6 +187,18 @@ export function useToolbarActions(emit) { } }; + // Paste through ProseMirror, as a native paste does, so the editor's and its + // extensions' paste props apply. `insertContent` skips them and parses text as HTML. + const pasteText = text => { + editor.value.commands.focus(); + editor.value.view.pasteText(text); + }; + + const pasteHTML = html => { + editor.value.commands.focus(); + editor.value.view.pasteHTML(html); + }; + const handlePaste = async () => { if (!editor.value) return; @@ -196,16 +210,13 @@ export function useToolbarActions(emit) { if (item.types.includes('text/html')) { const htmlBlob = await item.getType('text/html'); const html = await htmlBlob.text(); - const cleaned = transformPastedHTML(html); - - editor.value.chain().focus().insertContent(cleaned).run(); + pasteHTML(html); return; } if (item.types.includes('text/plain')) { const textBlob = await item.getType('text/plain'); const text = await textBlob.text(); - - editor.value.chain().focus().insertContent(text).run(); + pasteText(text); return; } } @@ -224,8 +235,7 @@ export function useToolbarActions(emit) { // Note: Genereted this regex with the help of LLM. const normalized = text.replace(/\r\n/g, '\n'); - - editor.value.chain().focus().insertContent(normalized).run(); + pasteText(normalized); } catch (err) { editor.value.chain().focus().insertContent(clipboardAccessFailed$()).run(); } @@ -407,24 +417,28 @@ export function useToolbarActions(emit) { }, ]); - const listActions = computed(() => [ - { - name: 'bulletList', - title: bulletList$(), - icon: require('../../assets/icon-bulletList.svg'), - handler: handleBulletList, - isActive: isMarkActive('bulletList'), - shouldFlipInRtl: true, - }, - { - name: 'numberList', - title: numberedList$(), - icon: require('../../assets/icon-numberList.svg'), - rtlIcon: require('../../assets/icon-numberListRTL.svg'), - handler: handleNumberList, - isActive: isMarkActive('orderedList'), - }, - ]); + const listActions = computed(() => + visible([ + { + name: 'bulletList', + title: bulletList$(), + icon: require('../../assets/icon-bulletList.svg'), + handler: handleBulletList, + isActive: isMarkActive('bulletList'), + shouldFlipInRtl: true, + blockOnly: true, + }, + { + name: 'numberList', + title: numberedList$(), + icon: require('../../assets/icon-numberList.svg'), + rtlIcon: require('../../assets/icon-numberListRTL.svg'), + handler: handleNumberList, + isActive: isMarkActive('orderedList'), + blockOnly: true, + }, + ]), + ); const scriptActions = computed(() => [ { @@ -452,6 +466,7 @@ export function useToolbarActions(emit) { title: insertImage$(), icon: require('../../assets/icon-insertImage.svg'), handler: handleInsertImage, + blockOnly: true, }, { name: 'link', @@ -476,6 +491,7 @@ export function useToolbarActions(emit) { icon: require('../../assets/icon-codeblock.svg'), handler: handleCodeBlock, isActive: isMarkActive('codeBlock'), + blockOnly: true, }, ]), ); @@ -499,21 +515,26 @@ export function useToolbarActions(emit) { handler: handleMinimize, }; - const alignAction = computed(() => { + const alignActions = computed(() => { + // Reading the effective alignment computes the selection's style on every + // transaction, for a button an inline-only editor never shows. + if (inlineOnly) return []; const editorInstance = editor?.value; const effectiveAlign = getEffectiveAlignment(editorInstance); const effectiveRight = effectiveAlign === 'right'; - return { - name: 'toggleAlign', - title: effectiveRight ? alignLeft$() : alignRight$(), - icon: effectiveRight - ? require('../../assets/icon-alignLeft.svg') - : require('../../assets/icon-alignRight.svg'), - handler: handleToggleAlign, - isActive: false, - isAvailable: !isMarkActive('codeBlock'), - }; + return [ + { + name: 'toggleAlign', + title: effectiveRight ? alignLeft$() : alignRight$(), + icon: effectiveRight + ? require('../../assets/icon-alignLeft.svg') + : require('../../assets/icon-alignRight.svg'), + handler: handleToggleAlign, + isActive: false, + isAvailable: !isMarkActive('codeBlock'), + }, + ]; }); return { @@ -543,7 +564,7 @@ export function useToolbarActions(emit) { // Action arrays historyActions, textActions, - alignAction, + alignActions, listActions, scriptActions, insertTools, diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/InlineDocument.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/InlineDocument.js new file mode 100644 index 0000000000..e64ef9a031 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/InlineDocument.js @@ -0,0 +1,16 @@ +import { Node } from '@tiptap/core'; + +// A top node holding one line of inline content, for fields that allow no blocks or +// line breaks. +export const InlineDocument = Node.create({ + name: 'doc', + topNode: true, + content: 'inline*', + + // Touch keyboards insert their newline before ProseMirror offers Enter to the keymap; + // claiming it makes ProseMirror discard that newline. + addKeyboardShortcuts() { + const insertNothing = () => true; + return { Enter: insertNothing, 'Shift-Enter': insertNothing, 'Mod-Enter': insertNothing }; + }, +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/inlineContent.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/inlineContent.js new file mode 100644 index 0000000000..494eb71fd4 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/inlineContent.js @@ -0,0 +1,37 @@ +// The elements an inline run cannot hold, whose content is kept and joined to its +// neighbours'. `small.small-text` is the full editor's Small block; a bare `small` +// is inline. +const BLOCK_SELECTOR = + 'address, article, aside, blockquote, canvas, center, dd, details, dialog, div, dl, dt, ' + + 'fieldset, figcaption, figure, footer, form, h1, h2, h3, h4, h5, h6, header, hgroup, ' + + 'legend, li, main, menu, nav, ol, output, p, pre, search, section, small.small-text, ' + + 'table, thead, tbody, tfoot, summary, tr, th, td, caption, ul'; + +// ProseMirror joins the text of adjacent blocks a schema cannot hold with nothing +// between them, so a space is put between each block and the content beside it. +// Each line break or rule becomes a space too; ProseMirror collapses the runs this leaves. +export function flattenBlocks(doc) { + // ProseMirror parses a copy marked as its own with the copy's whitespace and block + // depth, neither of which applies once flattened; unmarked, it collapses whitespace. + // https://github.com/ProseMirror/prosemirror-view/blob/master/src/clipboard.ts + doc.querySelectorAll('[data-pm-slice]').forEach(node => node.removeAttribute('data-pm-slice')); + // A `col` or `colgroup` as the first tag makes ProseMirror read the paste as a table, + // dropping its text. + doc.body.querySelectorAll('colgroup, col').forEach(node => node.remove()); + const blocks = [...doc.querySelectorAll(BLOCK_SELECTOR)]; + doc.querySelectorAll('br, hr').forEach(node => node.replaceWith(' ')); + // Innermost first: unwrapping an outer block first moves its nested blocks out one + // level at a time, which is quadratic in a pasted table. + blocks.reverse().forEach(block => { + if (block.previousSibling) block.before(' '); + if (block.nextSibling) block.after(' '); + block.replaceWith(...block.childNodes); + }); +} + +export function toInlineHTML(html) { + if (!html) return ''; + const doc = new DOMParser().parseFromString(html, 'text/html'); + flattenBlocks(doc); + return doc.body.innerHTML; +} diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/pasteTransform.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/pasteTransform.js index 8f805aa0e9..ce154db46f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/pasteTransform.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/pasteTransform.js @@ -1,3 +1,5 @@ +import { flattenBlocks } from './inlineContent'; + function stripMsoConditionalComments(html) { return html.replace(//gis, ''); } @@ -69,7 +71,7 @@ const DOM_TRANSFORMS = [ stripImages, ]; -export function transformPastedHTML(html) { +export function transformPastedHTML(html, { inlineOnly = false } = {}) { if (!html) return ''; let cleaned = html; for (const transform of STRING_TRANSFORMS) { @@ -79,5 +81,6 @@ export function transformPastedHTML(html) { for (const transform of DOM_TRANSFORMS) { transform(doc); } + if (inlineOnly) flattenBlocks(doc); return doc.body.innerHTML; } diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/EditorToolbar.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/EditorToolbar.spec.js index 02a6314137..1aeb54d6a3 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/EditorToolbar.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/EditorToolbar.spec.js @@ -1,12 +1,31 @@ -import { render, screen, within } from '@testing-library/vue'; +import { render, screen, waitFor, within } from '@testing-library/vue'; import userEvent from '@testing-library/user-event'; import { ref, nextTick } from 'vue'; import VueRouter from 'vue-router'; +import { Extension } from '@tiptap/core'; import EditorToolbar from '../TipTapEditor/components/EditorToolbar.vue'; import { getTipTapEditorStrings } from '../TipTapEditor/TipTapEditorStrings'; -import { tabIn } from 'shared/utils/testing'; - -const { textFormatOptions$, alignRight$ } = getTipTapEditorStrings(); +import { useEditor } from '../TipTapEditor/composables/useEditor'; +import { stubProseMirrorLayout, tabIn } from 'shared/utils/testing'; + +const { + textFormatOptions$, + alignRight$, + bold$, + italic$, + underline$, + strikethrough$, + subscript$, + superscript$, + mathFormula$, + bulletList$, + numberedList$, + insertImage$, + codeBlock$, + paste$, + pasteOptionsMenu$, + pasteWithoutFormatting$, +} = getTipTapEditorStrings(); // Every editor read the toolbar makes while rendering: undo/redo availability, // mark state, the alignment probe in `getEffectiveAlignment`, and the @@ -27,13 +46,14 @@ function makeEditorStub({ canUndo = true, canRedo = false } = {}) { // In jsdom every control measures 0 wide, so KListWithOverflow restores them all // and drops the more button — two ticks after the first render. -async function renderToolbar(editorOptions, { insertActions = [] } = {}) { +async function renderToolbar(editorOptions, { insertActions = [], inlineOnly = false } = {}) { const user = userEvent.setup(); const editor = makeEditorStub(editorOptions); const { container } = render(EditorToolbar, { provide: { editor: ref(editor), insertActions: ref(insertActions), + inlineOnly, insertContext: ref({ editor, selection: { empty: true, spansLines: false, hasCursor: true }, @@ -236,3 +256,165 @@ describe('EditorToolbar alignment control', () => { expect(screen.getByRole('button', { name: alignRight$() })).toBeInTheDocument(); }); }); + +describe('EditorToolbar in an inline-only editor', () => { + const INLINE_TOOLS = [ + bold$(), + italic$(), + underline$(), + strikethrough$(), + subscript$(), + superscript$(), + mathFormula$(), + ]; + const BLOCK_TOOLS = [ + textFormatOptions$(), + bulletList$(), + numberedList$(), + alignRight$(), + insertImage$(), + codeBlock$(), + ]; + + it('offers the inline tools', async () => { + await renderToolbar({}, { inlineOnly: true }); + + for (const name of INLINE_TOOLS) { + expect(screen.getByRole('button', { name })).toBeInTheDocument(); + } + }); + + it('leaves out every block tool', async () => { + await renderToolbar({}, { inlineOnly: true }); + + for (const name of BLOCK_TOOLS) { + expect(screen.queryByRole('button', { name })).not.toBeInTheDocument(); + } + }); + + it('renders no empty group', async () => { + const { container } = await renderToolbar({}, { inlineOnly: true }); + + for (const group of within(container).getAllByRole('group')) { + expect(within(group).queryAllByRole('button')).not.toEqual([]); + } + }); + + it('keeps the block tools in an editor that is not inline-only', async () => { + await renderToolbar(); + + for (const name of BLOCK_TOOLS) { + expect(screen.getByRole('button', { name })).toBeInTheDocument(); + } + }); +}); + +describe('EditorToolbar paste', () => { + stubProseMirrorLayout(); + + // jsdom has none; ProseMirror's `pasteHTML` and `pasteText` construct one. + beforeAll(() => { + global.ClipboardEvent = class ClipboardEvent extends Event {}; + }); + + afterAll(() => { + delete global.ClipboardEvent; + }); + + // After `userEvent.setup()`, which installs its own clipboard; jsdom's Blob has no `text()`. + const setClipboard = (type, data) => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + read: async () => [{ types: [type], getType: async () => ({ text: async () => data }) }], + readText: async () => data, + }, + }); + }; + + const renderWithClipboard = (type, data, { inlineOnly = true, extensions = [] } = {}) => { + const user = userEvent.setup(); + setClipboard(type, data); + const { initializeEditor, editor } = useEditor(); + initializeEditor('', 'edit', { inlineOnly, extensions }); + const { unmount } = render(EditorToolbar, { + provide: { editor, inlineOnly, insertActions: ref([]), insertContext: ref(null) }, + router: new VueRouter(), + }); + // The toolbar reads the editor as it re-renders, so it goes first. + teardown = () => { + unmount(); + editor.value.destroy(); + }; + return { user, editor: editor.value }; + }; + + let teardown; + afterEach(() => teardown()); + + const pasteFromToolbar = async (type, data, editorOptions) => { + const { user, editor } = renderWithClipboard(type, data, editorOptions); + await user.click(await screen.findByRole('button', { name: paste$() })); + return editor; + }; + + const pasteWithoutFormattingFromToolbar = async (text, editorOptions) => { + const { user, editor } = renderWithClipboard('text/plain', text, editorOptions); + await user.click(await screen.findByRole('button', { name: pasteOptionsMenu$() })); + await user.click(within(screen.getByRole('menu')).getByText(pasteWithoutFormatting$())); + return editor; + }; + + describe('in an editor that is not inline-only', () => { + const fullEditor = { inlineOnly: false }; + + it('applies extensions’ paste transforms to pasted HTML', async () => { + const ReplacePastedHTML = Extension.create({ + name: 'replacePastedHTML', + transformPastedHTML: () => '

replaced

', + }); + const editor = await pasteFromToolbar('text/html', '

a

', { + ...fullEditor, + extensions: [ReplacePastedHTML], + }); + + await waitFor(() => expect(editor.getHTML()).toBe('

replaced

')); + }); + + it('pastes each line of plain text as a paragraph', async () => { + const editor = await pasteFromToolbar('text/plain', 'a\nb', fullEditor); + + await waitFor(() => expect(editor.getHTML()).toBe('

a

b

')); + }); + + it('keeps markup in text pasted without formatting as text', async () => { + const editor = await pasteWithoutFormattingFromToolbar('a', fullEditor); + + await waitFor(() => expect(editor.getHTML()).toBe('

<b>a</b>

')); + }); + }); + + it('inserts pasted blocks as one inline run', async () => { + const editor = await pasteFromToolbar('text/html', '

a

  • b
'); + + await waitFor(() => expect(editor.getHTML()).toBe('a b')); + }); + + it('inserts a pasted phrase as text', async () => { + const editor = await pasteFromToolbar('text/html', 'a b'); + + await waitFor(() => expect(editor.getHTML()).toBe('a b')); + }); + + it('joins the lines of pasted plain text with a space', async () => { + const editor = await pasteFromToolbar('text/plain', 'a\nb'); + + await waitFor(() => expect(editor.getHTML()).toBe('a b')); + }); + + it('joins the lines of text pasted without formatting with a space', async () => { + const editor = await pasteWithoutFormattingFromToolbar('a\r\nb'); + + await waitFor(() => expect(editor.getHTML()).toBe('a b')); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/MobileFormattingBar.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/MobileFormattingBar.spec.js index 047089a6c7..dabf9bec83 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/MobileFormattingBar.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/MobileFormattingBar.spec.js @@ -15,7 +15,23 @@ jest.mock('shared/utils/browserInfo.js', () => { return jest.requireActual('shared/utils/browserInfo.js'); }); -const { decreaseFormatSize$, textFormattingToolbar$, alignRight$ } = getTipTapEditorStrings(); +const { + decreaseFormatSize$, + increaseFormatSize$, + textFormattingToolbar$, + alignRight$, + bold$, + italic$, + underline$, + strikethrough$, + subscript$, + superscript$, + mathFormula$, + bulletList$, + numberedList$, + insertImage$, + codeBlock$, +} = getTipTapEditorStrings(); const formattingBar = () => screen.queryByRole('toolbar', { name: textFormattingToolbar$() }); @@ -201,3 +217,55 @@ describe('MobileFormattingBar alignment control', () => { expect(screen.getByRole('button', { name: alignRight$() })).toBeInTheDocument(); }); }); + +describe('MobileFormattingBar in an inline-only editor', () => { + const INLINE_TOOLS = [ + bold$(), + italic$(), + underline$(), + strikethrough$(), + subscript$(), + superscript$(), + mathFormula$(), + ]; + const BLOCK_TOOLS = [ + decreaseFormatSize$(), + increaseFormatSize$(), + bulletList$(), + numberedList$(), + alignRight$(), + insertImage$(), + codeBlock$(), + ]; + + function renderBar({ inlineOnly }) { + return render(MobileFormattingBar, { + provide: { editor: ref(makeEditorStub()), inlineOnly }, + router: new VueRouter(), + }); + } + + it('offers the inline tools', () => { + renderBar({ inlineOnly: true }); + + for (const name of INLINE_TOOLS) { + expect(screen.getByRole('button', { name })).toBeInTheDocument(); + } + }); + + it('leaves out every block tool', () => { + renderBar({ inlineOnly: true }); + + for (const name of BLOCK_TOOLS) { + expect(screen.queryByRole('button', { name })).not.toBeInTheDocument(); + } + }); + + it('keeps the block tools in an editor that is not inline-only', () => { + renderBar({ inlineOnly: false }); + + for (const name of BLOCK_TOOLS) { + expect(screen.getByRole('button', { name })).toBeInTheDocument(); + } + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/MobileTopBar.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/MobileTopBar.spec.js index b6ba74c1e3..243adae688 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/MobileTopBar.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/MobileTopBar.spec.js @@ -15,7 +15,7 @@ jest.mock('shared/utils/browserInfo.js', () => { return jest.requireActual('shared/utils/browserInfo.js'); }); -const { insertContentMenu$, insertImage$ } = getTipTapEditorStrings(); +const { insertContentMenu$, insertImage$, mathFormula$, codeBlock$ } = getTipTapEditorStrings(); function makeEditorStub() { return { @@ -30,11 +30,12 @@ const insertContext = { canInsertNode: () => true, }; -function renderTopBar({ insertActions = [] } = {}) { +function renderTopBar({ insertActions = [], inlineOnly = false } = {}) { return render(MobileTopBar, { provide: { editor: ref(insertContext.editor), insertActions: ref(insertActions), + inlineOnly, insertContext: ref(insertContext), }, router: new VueRouter(), @@ -76,6 +77,19 @@ describe('MobileTopBar insert menu', () => { }); }); +describe('MobileTopBar insert menu in an inline-only editor', () => { + it('offers math only', async () => { + const user = userEvent.setup(); + renderTopBar({ inlineOnly: true }); + + const menu = await openInsertMenu(user); + + expect(within(menu).getByText(mathFormula$())).toBeInTheDocument(); + expect(within(menu).queryByText(insertImage$())).not.toBeInTheDocument(); + expect(within(menu).queryByText(codeBlock$())).not.toBeInTheDocument(); + }); +}); + describe('MobileTopBar contributed insert actions', () => { const makeAction = overrides => ({ name: 'inline', diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/TipTapEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/TipTapEditor.spec.js index a0269e48e9..69b9e97116 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/TipTapEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/TipTapEditor.spec.js @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/vue'; +import { fireEvent, render, screen, waitFor } from '@testing-library/vue'; import userEvent from '@testing-library/user-event'; import { nextTick } from 'vue'; import VueRouter from 'vue-router'; @@ -337,3 +337,100 @@ describe('TipTapEditor — closing on a click outside', () => { expect(minimize).not.toHaveBeenCalled(); }); }); + +/** + * Content stored before a field went inline-only can hold blocks, which ProseMirror + * would weld together on load. + */ +describe('TipTapEditor — inline-only mode', () => { + const BLOCKS = '

a

  • b
'; + + const renderEditor = async ({ inlineOnly, value = BLOCKS, format = 'html' }) => { + const ready = jest.fn(); + const update = jest.fn(); + const { container, updateProps } = render(TipTapEditor, { + props: { + value, + mode: 'edit', + format, + inlineOnly, + imageProcessor: { ACCEPTED_MIME_TYPES: ['image/png'] }, + }, + listeners: { ready, update }, + routes: new VueRouter(), + }); + await waitFor(() => expect(ready).toHaveBeenCalled()); + await nextTick(); + return { container, update, updateProps }; + }; + + const minimize = () => + userEvent.setup().click(screen.getByRole('button', { name: 'Minimize Toolbar' })); + + it('saves stored blocks back as one inline run', async () => { + const { update } = await renderEditor({ inlineOnly: true }); + + await minimize(); + + expect(update).toHaveBeenLastCalledWith('a b'); + }); + + it('reads and writes HTML whatever the format', async () => { + const value = '

a b

c

'; + const { update } = await renderEditor({ inlineOnly: true, value, format: 'markdown' }); + + await minimize(); + + expect(update).toHaveBeenLastCalledWith('a b c'); + }); + + it('flattens blocks set after it loads', async () => { + const { update, updateProps } = await renderEditor({ inlineOnly: true, value: 'x' }); + await updateProps({ value: BLOCKS }); + + await minimize(); + + expect(update).toHaveBeenLastCalledWith('a b'); + }); + + it('keeps blocks without it', async () => { + const { update } = await renderEditor({ inlineOnly: false }); + + await minimize(); + + const saved = update.mock.calls.at(-1)[0]; + expect(saved).toContain('
  • b

'); + expect(saved).toContain(' { + const { container } = await renderEditor({ inlineOnly: true }); + + expect(screen.getByRole('textbox', { name: /text editor/ })).toHaveAttribute( + 'aria-multiline', + 'false', + ); + expect(container.querySelector('[contenteditable]')).toHaveAttribute('aria-multiline', 'false'); + }); + + it('leaves a full editor multi-line', async () => { + const { container } = await renderEditor({ inlineOnly: false }); + + expect(screen.getByRole('textbox', { name: /text editor/ })).toHaveAttribute( + 'aria-multiline', + 'true', + ); + expect(container.querySelector('[contenteditable]')).not.toHaveAttribute('aria-multiline'); + }); + + it('ignores a dropped file', async () => { + const { container } = await renderEditor({ inlineOnly: true }); + const file = new File([''], 'image.png', { type: 'image/png' }); + + await fireEvent.drop(container.querySelector('.editor-content'), { + dataTransfer: { files: [file] }, + }); + + expect(screen.queryAllByRole('dialog')).toEqual([]); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/inlineContent.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/inlineContent.spec.js new file mode 100644 index 0000000000..c101378fba --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/inlineContent.spec.js @@ -0,0 +1,90 @@ +import { useEditor } from '../TipTapEditor/composables/useEditor'; +import { toInlineHTML } from '../TipTapEditor/utils/inlineContent'; + +describe('toInlineHTML', () => { + let editors = []; + + // Read back through an inline-only editor: its parser collapses the whitespace + // the unwrapped blocks leave, so what it holds is the outcome that matters. + const flatten = html => { + const { initializeEditor, editor } = useEditor(); + initializeEditor(toInlineHTML(html), 'edit', { inlineOnly: true }); + editors.push(editor.value); + return editor.value.getHTML(); + }; + + afterEach(() => { + editors.forEach(editor => editor.destroy()); + editors = []; + }); + + it.each([ + ['paragraphs', '

a

b

', 'a b'], + ['headings', '

a

b

', 'a b'], + ['list items', '
  • a
  • b
', 'a b'], + ['nested list items', '
  • a

    • b

', 'a b'], + ['table cells', '
ab
', 'a b'], + ['divs', '
a
b
', 'a b'], + [ + 'small text blocks', + 'ab', + 'a b', + ], + ['centered blocks', '
a
b
', 'a b'], + ['details', '
ab
', 'a b'], + ['dialogs', 'ab', 'a b'], + ['menus', '
  • a
  • b
  • ', 'a b'], + ['search blocks', 'ab', 'a b'], + ['indented blocks', '
      \n
    • a
    • \n
    • \n b\n
    • \n
    \n', 'a b'], + ['a code block', '
    a\nb
    ', 'a b'], + ])('joins %s with a single space', (_, html, expected) => { + expect(flatten(html)).toBe(expected); + }); + + it('keeps the inline content of a block', () => { + const inline = + 'b i s S c x ' + + 'u '; + + expect(flatten(`

    ${inline}

    `)).toBe(inline); + }); + + it('keeps inline small text in place', () => { + expect(flatten('

    fine print here

    ')).toBe('fine print here'); + }); + + it.each([ + ['', '

    a

    b

    '], + [' followed by whitespace', '

    a
    \n

    b

    '], + ])("drops a block's trailing break%s", (_, html) => { + expect(flatten(html)).toBe('a b'); + }); + + it.each([ + ['a
    b', 'a b'], + ['

    a
    b

    ', 'a b'], + ['

    a

    ', 'a '], + ])('turns the line break in %j into a space', (html, expected) => { + expect(flatten(html)).toBe(expected); + }); + + it('keeps the space between math and the text beside it', () => { + const inline = ' and '; + + expect(flatten(`

    ${inline}

    `)).toBe(inline); + }); + + it('separates a block from math that starts the next block', () => { + expect(flatten('

    a

    is it

    ')).toBe( + 'a is it', + ); + }); + + it.each([ + ['an image', '

    a

    b

    '], + ['a horizontal rule', '

    a


    b

    '], + ['a horizontal rule between runs of text', 'a
    b'], + ])('drops %s', (_, html) => { + expect(flatten(html)).toBe('a b'); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useEditor.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useEditor.spec.js index 1a31a4645c..d47e2e84af 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useEditor.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/useEditor.spec.js @@ -3,6 +3,9 @@ import { useEditor } from '../TipTapEditor/composables/useEditor'; import { transformPastedHTML } from '../TipTapEditor/utils/pasteTransform'; import { stubProseMirrorLayout } from 'shared/utils/testing'; +// jsdom has no ClipboardEvent, which is what the paste methods build without one. +const pasteEvent = () => new Event('paste'); + /** * The QTI 3.0 HTML profile has no or , so an item carrying either is rejected by * the item schema. It does have , and the schema admits a `style` attribute, so @@ -88,6 +91,131 @@ describe('the editor schema', () => { }); }); +/** + * An inline-only editor has no block nodes or line breaks at all: a block command, + * input rule or line-break key then has nothing to create. + */ +describe('the inline-only schema', () => { + const INLINE_ELEMENTS = ['strong', 'em', 'span', 'sub', 'sup', 'code']; + + let editors = []; + + const createEditor = content => { + const { initializeEditor, editor } = useEditor(); + initializeEditor(content, 'edit', { inlineOnly: true }); + editors.push(editor.value); + return editor.value; + }; + + const elementsIn = html => { + const container = document.createElement('div'); + container.innerHTML = html; + return Array.from(container.querySelectorAll('*'), element => element.localName); + }; + + // Input rules run from the view's text input handler, one character at a time. + const type = (editor, text) => { + for (const char of text) { + const { from, to } = editor.state.selection; + const handled = editor.view.someProp('handleTextInput', handler => + handler(editor.view, from, to, char), + ); + if (!handled) editor.view.dispatch(editor.state.tr.insertText(char, from, to)); + } + }; + + afterEach(() => { + editors.forEach(editor => editor.destroy()); + editors = []; + }); + + it('writes every offered feature as inline elements only', () => { + const editor = createEditor( + 'bi' + + 'u' + + 's' + + '12c', + ); + const html = editor.getHTML(); + + expect([...new Set(elementsIn(html))].sort()).toEqual([...INLINE_ELEMENTS].sort()); + expect(html).toContain('data-latex="x^2"'); + }); + + it.each([ + ['heading', 'Mod-Alt-1'], + ['bullet list', 'Mod-Shift-8'], + ['ordered list', 'Mod-Shift-7'], + ['blockquote', 'Mod-Shift-b'], + ['code block', 'Mod-Alt-c'], + ['small text', 'Mod-Shift-S'], + ])('creates no block from the %s shortcut', (_, shortcut) => { + const editor = createEditor('text'); + editor.commands.focus('end'); + editor.commands.keyboardShortcut(shortcut); + + expect(elementsIn(editor.getHTML())).toEqual([]); + expect(editor.getText()).toBe('text'); + }); + + it.each([ + ['heading', '# '], + ['bullet list', '- '], + ['ordered list', '1. '], + ['blockquote', '> '], + ['code block', '```'], + ['horizontal rule', '---'], + ])('creates no block from the %s input rule', (_, typed) => { + const editor = createEditor(''); + type(editor, typed); + + expect(elementsIn(editor.getHTML())).toEqual([]); + expect(editor.getText()).toBe(typed); + }); + + // Touch keyboards edit the DOM before ProseMirror offers Enter to the keymap; + // an unclaimed Enter lets their newline into the doc. + it.each([ + ['Enter', {}], + ['Shift-Enter', { shiftKey: true }], + ['Mod-Enter', { ctrlKey: true }], + ])('claims %s and inserts nothing', (_, modifiers) => { + const editor = createEditor('ab'); + editor.commands.setTextSelection(1); + const enter = new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, ...modifiers }); + + expect(editor.view.someProp('handleKeyDown', handler => handler(editor.view, enter))).toBe( + true, + ); + expect(editor.getHTML()).toBe('ab'); + }); + + it.each(['insertParagraph', 'insertLineBreak'])('cancels the %s input', inputType => { + const editor = createEditor('ab'); + const input = new InputEvent('beforeinput', { inputType, cancelable: true }); + editor.view.dom.dispatchEvent(input); + + expect(input.defaultPrevented).toBe(true); + }); + + it('lets the insertText input through', () => { + const editor = createEditor('ab'); + const input = new InputEvent('beforeinput', { inputType: 'insertText', cancelable: true }); + editor.view.dom.dispatchEvent(input); + + expect(input.defaultPrevented).toBe(false); + }); + + it('undoes an edit', () => { + const editor = createEditor('a'); + editor.commands.focus('end'); + editor.commands.insertContent('b'); + editor.commands.undo(); + + expect(editor.getHTML()).toBe('a'); + }); +}); + /** * A pasted decoration is what this schema had no mark for until now: the two were * switched off in StarterKit, so a or a decorated arrived as plain text. @@ -121,6 +249,183 @@ describe('a pasted decoration', () => { }); }); +/** + * ProseMirror joins the text of blocks an inline-only schema cannot hold with nothing + * between them, so a paste would weld the last word of one line to the next. + */ +describe('a paste into an inline-only editor', () => { + let editor; + + beforeEach(() => { + const { initializeEditor, editor: instance } = useEditor(); + initializeEditor('', 'edit', { inlineOnly: true }); + editor = instance.value; + }); + + afterEach(() => { + editor.destroy(); + }); + + it('arrives as one run keeping its marks and math', () => { + editor.view.pasteHTML( + '

    Title

    • bold
    • ' + + '
    • math
    ', + pasteEvent(), + ); + + expect(editor.getHTML()).toBe( + 'Title bold math ', + ); + }); + + it('reads the Google Docs wrapper as a container, not a bold run', () => { + editor.view.pasteHTML( + '

    one

    two

    ', + pasteEvent(), + ); + + expect(editor.getHTML()).toBe('one two'); + }); + + it('joins the lines of plain text with a space', () => { + editor.view.pasteText('one \ntwo\r\n\r\n three', pasteEvent()); + + expect(editor.getHTML()).toBe('one two three'); + }); + + it("drops the placeholder breaks of a full editor's empty and hard-break-ended lines", () => { + editor.view.pasteHTML( + '

    one


    ' + + '

    two

    three

    ', + pasteEvent(), + ); + + expect(editor.getHTML()).toBe('one two three'); + }); + + it("separates a full editor's small text lines", () => { + editor.view.pasteHTML( + '

    Intro

    first line' + + 'second line

    end

    ', + pasteEvent(), + ); + + expect(editor.getHTML()).toBe('Intro first line second line end'); + }); + + it('separates blocks with a comment between them', () => { + editor.view.pasteHTML('

    one

    two

    ', pasteEvent()); + + expect(editor.getHTML()).toBe('one two'); + }); + + it.each([ + [ + 'Excel', + '' + + '
    redblue
    ', + 'red blue', + ], + [ + 'LibreOffice Calc', + '
    redblue
    ', + 'red blue', + ], + [ + 'a table after text', + 'Intro
    redblue
    ', + 'Intro red blue', + ], + ])('keeps the text of a spreadsheet paste from %s', (_, html, expected) => { + editor.view.pasteHTML(html, pasteEvent()); + + expect(editor.getHTML()).toBe(expected); + }); + + it.each([ + ['

    one


    two

    ', 'one two'], + ['one
    \n two', 'one two'], + ['one
    two', 'one two'], + ])('turns a line break into one space in %j', (html, expected) => { + editor.view.pasteHTML(html, pasteEvent()); + + expect(editor.getHTML()).toBe(expected); + }); +}); + +/** + * A copy carries markup around its content, and a ProseMirror copy the blocks it was + * cut from, which an inline-only editor has none of. + */ +describe('a copy pasted into an inline-only editor', () => { + let editors = []; + + const createEditor = (content, inlineOnly) => { + const { initializeEditor, editor } = useEditor(); + initializeEditor(content, 'edit', { inlineOnly }); + editors.push(editor.value); + return editor.value; + }; + + const copy = (editor, from, to) => + editor.view.serializeForClipboard(editor.state.doc.slice(from, to, true)).dom.innerHTML; + + const pasteBetween = html => { + const editor = createEditor('xy', true); + editor.commands.setTextSelection(1); + editor.view.pasteHTML(html, pasteEvent()); + return editor.getText(); + }; + + afterEach(() => { + editors.forEach(editor => editor.destroy()); + editors = []; + }); + + // Chromium on Windows wraps copied HTML in line-broken markup. + it('drops the Windows clipboard wrapper of a marked copy around a pasted phrase', () => { + const html = + '\r\n\r\nhello\r\n\r\n'; + + expect(pasteBetween(html)).toBe('xhelloy'); + }); + + it('joins copied paragraphs with one space', () => { + const html = copy(createEditor('

    one

    two

    ', false), 1, 11); + + expect(pasteBetween(html)).toBe('xone twoy'); + }); + + it.each([ + ['paragraphs around', 1, 'xa by'], + ['a paragraph after', 3, 'xby'], + ])('drops a copied horizontal rule with %s it', (_, from, expected) => { + const source = createEditor('

    a


    b

    ', false); + const html = copy(source, from, source.state.doc.content.size - 1); + + expect(pasteBetween(html)).toBe(expected); + }); + + // Chromium on macOS and Linux prefixes copied HTML with a meta tag. + it.each([ + ['a charset', ''], + ['a content-type', ''], + ])('joins copied paragraphs prefixed with %s meta tag with one space', (_, meta) => { + const html = copy(createEditor('

    one

    two

    ', false), 1, 9); + + expect(pasteBetween(meta + html)).toBe('xone twoy'); + }); + + it("flattens a copied code block's lines as stored content does", () => { + const source = createEditor('

    intro

    if x:\n    y()
    ', false); + const html = copy(source, 1, source.state.doc.content.size - 1); + const editor = createEditor('', true); + editor.view.pasteHTML(html, pasteEvent()); + + expect(editor.getHTML()).toBe('intro if x: y()'); + }); +}); + describe('the insert context', () => { const Widget = Node.create({ name: 'widget', diff --git a/docs/rich_text_editor.md b/docs/rich_text_editor.md index 0c492d35bf..75b66e7f4b 100644 --- a/docs/rich_text_editor.md +++ b/docs/rich_text_editor.md @@ -28,7 +28,7 @@ This is a very high level guide, you'll still need to check the docs but make su `TipTapEditor/extensions/` 2. Define your node or mark using TipTap’s `Node.create()` or `Mark.create()`. 3. Register it: - - for every editor: add it to the editor’s extension list in `TipTapEditor/composables/useEditor.js`. + - for every editor: add it to the editor’s extension list in `TipTapEditor/composables/useEditor.js`. A block node goes in the `inlineOnly`-false branch, and its toolbar actions get `blockOnly: true`. - for one consumer only: pass it in the `extensions` prop (see [Extending the editor from a consumer](#extending-the-editor-from-a-consumer)). 4. If your node needs Markdown support, update the custom serializer in `TipTapEditor/utils/MarkdownSerializer.js` and don't forget to update the tests accordingly! --- @@ -40,6 +40,18 @@ A component that renders `TipTapEditor` can add behaviour without editing the ed - Read once, when the editor is created; later changes are ignored. - The markdown serializer drops nodes it doesn't know: with the default `format="markdown"`, a contributed node is lost on save. Use `format="html"`. +### `inlineOnly` prop +- The field holds one line of inline content. +- Offers bold, italic, underline, strikethrough, subscript, superscript, math, inline code and undo/redo. +- Block nodes and the line break are left out of the schema, so no toolbar, shortcut or input rule creates one. +- `Enter`, `Shift-Enter` and `Mod-Enter` insert nothing. +- Blocks and line breaks in the value or a paste become spaces. +- Images in the value or a paste are dropped. +- Stored blocks are rewritten on the next save. +- `insertActions` handlers and `@ready` listeners insert inline content only: `insertContent` with a block throws on an empty editor and welds its text to its neighbours otherwise. +- Read once, when the editor is created. +- Reads and writes HTML whatever `format` says: the markdown serializer assumes block children. + ### `@ready` event - Emitted once, with the tiptap `Editor`, after its `create` event; commands are safe from then on. - Subscribe with `editor.on(…)`; unsubscribe with `editor.off(…)` on unmount.