diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 6a4109770c..b03d719c91 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -398,6 +398,7 @@ "github_workflow_authorize": "", "github_workflow_files_delete_github_repo": "", "github_workflow_files_error": "", + "give_feedback": "", "go_next_page": "", "go_page": "", "go_prev_page": "", @@ -715,6 +716,9 @@ "password": "", "password_managed_externally": "", "password_was_detected_on_a_public_list_of_known_compromised_passwords": "", + "paste_options": "", + "paste_with_formatting": "", + "paste_without_formatting": "", "payment_provider_unreachable_error": "", "payment_summary": "", "pdf_compile_in_progress_error": "", diff --git a/services/web/frontend/js/features/source-editor/components/paste-html/pasted-content-menu.tsx b/services/web/frontend/js/features/source-editor/components/paste-html/pasted-content-menu.tsx new file mode 100644 index 0000000000..eaa5c80aac --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/paste-html/pasted-content-menu.tsx @@ -0,0 +1,173 @@ +import { + FC, + HTMLProps, + PropsWithChildren, + useEffect, + useRef, + useState, +} from 'react' +import Icon from '../../../../shared/components/icon' +import { Overlay, Popover } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import { EditorView } from '@codemirror/view' +import { PastedContent } from '../../extensions/visual/pasted-content' +import useEventListener from '../../../../shared/hooks/use-event-listener' +import SplitTestBadge from '../../../../shared/components/split-test-badge' +import { useSplitTestContext } from '../../../../shared/context/split-test-context' + +const isMac = /Mac/.test(window.navigator?.platform) + +export const PastedContentMenu: FC<{ + insertPastedContent: ( + view: EditorView, + pastedContent: PastedContent, + formatted: boolean + ) => void + pastedContent: PastedContent + view: EditorView + formatted: boolean +}> = ({ view, insertPastedContent, pastedContent, formatted }) => { + const [menuOpen, setMenuOpen] = useState(false) + const toggleButtonRef = useRef(null) + const { t } = useTranslation() + const { splitTestInfo } = useSplitTestContext() + const feedbackURL = splitTestInfo['paste-html']?.badgeInfo?.url + + // record whether the Shift key is currently down, for use in the `paste` event handler + const shiftRef = useRef(false) + useEventListener('keydown', (event: KeyboardEvent) => { + shiftRef.current = event.shiftKey + }) + + useEffect(() => { + if (menuOpen) { + const abortController = new AbortController() + view.dom.addEventListener( + 'paste', + event => { + event.preventDefault() + event.stopPropagation() + insertPastedContent(view, pastedContent, !shiftRef.current) + setMenuOpen(false) + }, + { signal: abortController.signal, capture: true } + ) + return () => { + abortController.abort() + } + } + }, [view, menuOpen, pastedContent, insertPastedContent]) + + // TODO: keyboard navigation + + return ( + <> + + + {menuOpen && ( + setMenuOpen(false)} + animation={false} + container={view.dom} + containerPadding={0} + placement="bottom" + rootClose + target={toggleButtonRef.current ?? undefined} + > + + + + + )} + + ) +} + +const MenuItem = ({ + children, + ...buttonProps +}: PropsWithChildren>) => ( + +) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/paste-html.ts b/services/web/frontend/js/features/source-editor/extensions/visual/paste-html.ts index 702a36e9cd..d6fb4e3c2e 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/paste-html.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/paste-html.ts @@ -1,82 +1,78 @@ import { EditorView } from '@codemirror/view' -import { EditorSelection, Prec } from '@codemirror/state' -import { ancestorNodeOfType } from '../../utils/tree-query' +import { Prec } from '@codemirror/state' +import { + insertPastedContent, + pastedContent, + storePastedContent, +} from './pasted-content' -export const pasteHtml = Prec.highest( - EditorView.domEventHandlers({ - paste(event, view) { - const { clipboardData } = event +export const pasteHtml = [ + Prec.highest( + EditorView.domEventHandlers({ + paste(event, view) { + const { clipboardData } = event - if (!clipboardData) { - return false - } - - // allow pasting an image to create a figure - if (clipboardData.files.length > 0) { - return false - } - - // only handle pasted HTML - if (!clipboardData.types.includes('text/html')) { - return false - } - - // ignore text/html from VS Code - if ( - clipboardData.types.includes('application/vnd.code.copymetadata') || - clipboardData.types.includes('vscode-editor-data') - ) { - return false - } - - const html = clipboardData.getData('text/html').trim() - const text = clipboardData.getData('text/plain').trim() - - if (html.length === 0) { - return false - } - - // convert the HTML to LaTeX - try { - const parser = new DOMParser() - const { documentElement } = parser.parseFromString(html, 'text/html') - - // if the only content is in a code block, use the plain text version - if (onlyCode(documentElement)) { + if (!clipboardData) { return false } - const latex = htmlToLaTeX(documentElement) + // allow pasting an image to create a figure + if (clipboardData.files.length > 0) { + return false + } - view.dispatch( - view.state.changeByRange(range => { - // avoid pasting formatted content into a math container - if ( - ancestorNodeOfType(view.state, range.anchor, '$MathContainer') - ) { - return { - range: EditorSelection.cursor(range.from + text.length), - changes: { from: range.from, to: range.to, insert: text }, - } - } + // only handle pasted HTML + if (!clipboardData.types.includes('text/html')) { + return false + } - return { - range: EditorSelection.cursor(range.from + latex.length), - changes: { from: range.from, to: range.to, insert: latex }, - } - }) - ) + // ignore text/html from VS Code + if ( + clipboardData.types.includes('application/vnd.code.copymetadata') || + clipboardData.types.includes('vscode-editor-data') + ) { + return false + } - return true - } catch (error) { - console.error(error) + const html = clipboardData.getData('text/html').trim() + const text = clipboardData.getData('text/plain').trim() - // fall back to the default paste handler - return false - } - }, - }) -) + if (html.length === 0) { + return false + } + + // convert the HTML to LaTeX + try { + const parser = new DOMParser() + const { documentElement } = parser.parseFromString(html, 'text/html') + + // if the only content is in a code block, use the plain text version + if (onlyCode(documentElement)) { + return false + } + + const latex = htmlToLaTeX(documentElement) + + // if there's no formatting, use the plain text version + if (latex === text) { + return false + } + + view.dispatch(insertPastedContent(view, { latex, text })) + view.dispatch(storePastedContent({ latex, text }, true)) + + return true + } catch (error) { + console.error(error) + + // fall back to the default paste handler + return false + } + }, + }) + ), + pastedContent, +] const removeUnwantedElements = ( documentElement: HTMLElement, diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/pasted-content.tsx b/services/web/frontend/js/features/source-editor/extensions/visual/pasted-content.tsx new file mode 100644 index 0000000000..c28b2a80f7 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/pasted-content.tsx @@ -0,0 +1,192 @@ +import { + EditorSelection, + Range, + StateEffect, + StateField, +} from '@codemirror/state' +import { Decoration, EditorView, WidgetType } from '@codemirror/view' +import { undo } from '@codemirror/commands' +import { ancestorNodeOfType } from '../../utils/tree-operations/ancestors' +import ReactDOM from 'react-dom' +import { PastedContentMenu } from '../../components/paste-html/pasted-content-menu' +import { SplitTestProvider } from '../../../../shared/context/split-test-context' + +export type PastedContent = { latex: string; text: string } + +const pastedContentEffect = + StateEffect.define<{ content: PastedContent; formatted: boolean }>() + +export const insertPastedContent = ( + view: EditorView, + { latex, text }: PastedContent +) => + view.state.changeByRange(range => { + // avoid pasting formatted content into a math container + if (ancestorNodeOfType(view.state, range.anchor, '$MathContainer')) { + return { + range: EditorSelection.cursor(range.from + text.length), + changes: { from: range.from, to: range.to, insert: text }, + } + } + + return { + range: EditorSelection.cursor(range.from + latex.length), + changes: { from: range.from, to: range.to, insert: latex }, + } + }) + +export const storePastedContent = ( + content: PastedContent, + formatted: boolean +) => ({ + effects: pastedContentEffect.of({ content, formatted }), +}) + +export const pastedContent = StateField.define<{ + content: PastedContent + formatted: boolean + selection: EditorSelection +} | null>({ + create() { + return null + }, + update(value, tr) { + if (tr.docChanged) { + // TODO: exclude remote changes (if they don't intersect with changed ranges)? + value = null + } else { + for (const effect of tr.effects) { + if (effect.is(pastedContentEffect)) { + value = { + ...effect.value, + selection: tr.state.selection, + } + } + } + } + + return value + }, + provide(field) { + return [ + EditorView.decorations.compute([field], state => { + const value = state.field(field) + + if (!value) { + return Decoration.none + } + + const decorations: Range[] = [] + + const { content, selection, formatted } = value + decorations.push( + Decoration.widget({ + widget: new PastedContentMenuWidget(content, formatted), + side: 1, + }).range(selection.main.to) + ) + + return Decoration.set(decorations, true) + }), + EditorView.baseTheme({ + '.ol-cm-pasted-content-menu-toggle': { + background: 'none', + borderRadius: '8px', + border: '1px solid rgb(125, 125, 125)', + margin: '0 4px', + opacity: '0.7', + '&:hover': { + opacity: '1', + }, + }, + '.ol-cm-pasted-content-menu-popover': { + maxWidth: 'unset', + '& .popover-content': { + padding: 0, + }, + }, + '&dark .ol-cm-pasted-content-menu-popover': { + background: 'rgba(0, 0, 0)', + }, + '.ol-cm-pasted-content-menu': { + display: 'flex', + flexDirection: 'column', + boxSizing: 'border-box', + fontSize: '14px', + }, + '.ol-cm-pasted-content-menu-item': { + border: 'none', + background: 'none', + padding: '8px 16px', + width: '100%', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + whiteSpace: 'nowrap', + gap: '12px', + '&[aria-disabled="true"]': { + color: 'rgba(125, 125, 125, 0.5)', + }, + '&:hover': { + backgroundColor: 'rgba(125, 125, 125, 0.2)', + }, + }, + '.ol-cm-pasted-content-menu-item-label': { + flex: 1, + textAlign: 'left', + }, + '.ol-cm-pasted-content-menu-item-shortcut': { + textAlign: 'right', + }, + }), + ] + }, +}) + +class PastedContentMenuWidget extends WidgetType { + constructor( + private pastedContent: PastedContent, + private formatted: boolean + ) { + super() + } + + toDOM(view: EditorView) { + const element = document.createElement('span') + ReactDOM.render( + + + , + element + ) + return element + } + + insertPastedContent( + view: EditorView, + pastedContent: PastedContent, + formatted: boolean + ) { + undo(view) + view.dispatch( + insertPastedContent(view, { + latex: formatted ? pastedContent.latex : pastedContent.text, + text: pastedContent.text, + }) + ) + view.dispatch(storePastedContent(pastedContent, formatted)) + view.focus() + } + + eq(widget: PastedContentMenuWidget) { + return ( + widget.pastedContent === this.pastedContent && + widget.formatted === this.formatted + ) + } +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index c65771fc0a..0e2ea85210 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1179,6 +1179,9 @@ "password_too_long_please_reset": "Maximum password length exceeded. Please reset your password.", "password_updated": "Password updated", "password_was_detected_on_a_public_list_of_known_compromised_passwords": "This password was detected on a <0>public list of known compromised passwords", + "paste_options": "Paste options", + "paste_with_formatting": "Paste with formatting", + "paste_without_formatting": "Paste without formatting", "payment_method_accepted": "__paymentMethod__ accepted", "payment_provider_unreachable_error": "Sorry, there was an error talking to our payment provider. Please try again in a few moments.\nIf you are using any ad or script blocking extensions in your browser, you may need to temporarily disable them.", "payment_summary": "Payment summary",