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
');
+
+ 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
';
+
+ 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('');
+ 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'],
+ ['nested list items', '', 'a b'],
+ ['table cells', '', 'a b'],
+ ['divs', 'a
b
', 'a b'],
+ [
+ 'small text blocks',
+ 'ab',
+ 'a b',
+ ],
+ ['centered blocks', 'ab', 'a b'],
+ ['details', 'a
b ', 'a b'],
+ ['dialogs', '', 'a b'],
+ ['menus', '', 'a b'],
+ ['search blocks', 'ab', 'a b'],
+ ['indented blocks', '\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
',
+ 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 lineend
',
+ 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',
+ '',
+ 'red blue',
+ ],
+ [
+ 'LibreOffice Calc',
+ '',
+ 'red blue',
+ ],
+ [
+ 'a table after text',
+ 'Intro',
+ '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.