Просмотр исходного кода

[visual] Provide UI for toggling between plain text and formatted content after pasting (#14568)

GitOrigin-RevId: 3592d5507090a179d68e8e5f56f9e885639cac76
Alf Eaton 2 лет назад
Родитель
Сommit
7004b5bacf

+ 4 - 0
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": "",

+ 173 - 0
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<HTMLButtonElement | null>(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 (
+    <>
+      <button
+        ref={toggleButtonRef}
+        type="button"
+        id="pasted-content-menu-button"
+        aria-haspopup="true"
+        aria-expanded={menuOpen}
+        aria-controls="pasted-content-menu"
+        aria-label={t('paste_options')}
+        className="ol-cm-pasted-content-menu-toggle"
+        tabIndex={0}
+        onMouseDown={event => event.preventDefault()}
+        onClick={() => setMenuOpen(isOpen => !isOpen)}
+        style={{ userSelect: 'none' }}
+      >
+        <Icon type="clipboard" fw />
+        <Icon type="caret-down" fw />
+      </button>
+
+      {menuOpen && (
+        <Overlay
+          show
+          onHide={() => setMenuOpen(false)}
+          animation={false}
+          container={view.dom}
+          containerPadding={0}
+          placement="bottom"
+          rootClose
+          target={toggleButtonRef.current ?? undefined}
+        >
+          <Popover
+            id="popover-pasted-content-menu"
+            className="ol-cm-pasted-content-menu-popover"
+          >
+            <div
+              className="ol-cm-pasted-content-menu"
+              id="pasted-content-menu"
+              role="menu"
+              aria-labelledby="pasted-content-menu-button"
+            >
+              <MenuItem
+                onClick={() => {
+                  insertPastedContent(view, pastedContent, true)
+                  setMenuOpen(false)
+                }}
+              >
+                <span style={{ visibility: formatted ? 'visible' : 'hidden' }}>
+                  <Icon type="check" fw />
+                </span>
+                <span className="ol-cm-pasted-content-menu-item-label">
+                  {t('paste_with_formatting')}
+                </span>
+                <span className="ol-cm-pasted-content-menu-item-shortcut">
+                  {isMac ? '⌘V' : 'Ctrl+V'}
+                </span>
+              </MenuItem>
+
+              <MenuItem
+                onClick={() => {
+                  insertPastedContent(view, pastedContent, false)
+                  setMenuOpen(false)
+                }}
+              >
+                <span style={{ visibility: formatted ? 'hidden' : 'visible' }}>
+                  <Icon type="check" fw />
+                </span>
+                <span className="ol-cm-pasted-content-menu-item-label">
+                  {t('paste_without_formatting')}
+                </span>
+                <span className="ol-cm-pasted-content-menu-item-shortcut">
+                  {isMac ? '⇧⌘V' : 'Ctrl+Shift+V'}
+                </span>
+              </MenuItem>
+
+              <MenuItem
+                style={{ borderTop: '1px solid #eee' }}
+                onClick={() => {
+                  window.open(feedbackURL, '_blank')
+                  setMenuOpen(false)
+                }}
+              >
+                <SplitTestBadge
+                  splitTestName="paste-html"
+                  displayOnVariants={['enabled']}
+                />
+                <span className="ol-cm-pasted-content-menu-item-label">
+                  {t('give_feedback')}
+                </span>
+              </MenuItem>
+            </div>
+          </Popover>
+        </Overlay>
+      )}
+    </>
+  )
+}
+
+const MenuItem = ({
+  children,
+  ...buttonProps
+}: PropsWithChildren<HTMLProps<HTMLButtonElement>>) => (
+  <button
+    {...buttonProps}
+    type="button"
+    role="menuitem"
+    className="ol-cm-pasted-content-menu-item"
+  >
+    {children}
+  </button>
+)

+ 63 - 67
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
+
+        if (!clipboardData) {
+          return false
+        }
 
-export const pasteHtml = Prec.highest(
-  EditorView.domEventHandlers({
-    paste(event, view) {
-      const { clipboardData } = event
+        // allow pasting an image to create a figure
+        if (clipboardData.files.length > 0) {
+          return false
+        }
 
-      if (!clipboardData) {
-        return false
-      }
+        // only handle pasted HTML
+        if (!clipboardData.types.includes('text/html')) {
+          return false
+        }
 
-      // allow pasting an image to create a figure
-      if (clipboardData.files.length > 0) {
-        return false
-      }
+        // ignore text/html from VS Code
+        if (
+          clipboardData.types.includes('application/vnd.code.copymetadata') ||
+          clipboardData.types.includes('vscode-editor-data')
+        ) {
+          return false
+        }
 
-      // only handle pasted HTML
-      if (!clipboardData.types.includes('text/html')) {
-        return false
-      }
+        const html = clipboardData.getData('text/html').trim()
+        const text = clipboardData.getData('text/plain').trim()
 
-      // ignore text/html from VS Code
-      if (
-        clipboardData.types.includes('application/vnd.code.copymetadata') ||
-        clipboardData.types.includes('vscode-editor-data')
-      ) {
-        return false
-      }
+        if (html.length === 0) {
+          return false
+        }
 
-      const html = clipboardData.getData('text/html').trim()
-      const text = clipboardData.getData('text/plain').trim()
+        // convert the HTML to LaTeX
+        try {
+          const parser = new DOMParser()
+          const { documentElement } = parser.parseFromString(html, 'text/html')
 
-      if (html.length === 0) {
-        return false
-      }
+          // 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))
 
-      // convert the HTML to LaTeX
-      try {
-        const parser = new DOMParser()
-        const { documentElement } = parser.parseFromString(html, 'text/html')
+          return true
+        } catch (error) {
+          console.error(error)
 
-        // if the only content is in a code block, use the plain text version
-        if (onlyCode(documentElement)) {
+          // fall back to the default paste handler
           return false
         }
-
-        const latex = htmlToLaTeX(documentElement)
-
-        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 },
-              }
-            }
-
-            return {
-              range: EditorSelection.cursor(range.from + latex.length),
-              changes: { from: range.from, to: range.to, insert: latex },
-            }
-          })
-        )
-
-        return true
-      } catch (error) {
-        console.error(error)
-
-        // fall back to the default paste handler
-        return false
-      }
-    },
-  })
-)
+      },
+    })
+  ),
+  pastedContent,
+]
 
 const removeUnwantedElements = (
   documentElement: HTMLElement,

+ 192 - 0
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<Decoration>[] = []
+
+        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(
+      <SplitTestProvider>
+        <PastedContentMenu
+          insertPastedContent={this.insertPastedContent}
+          view={view}
+          formatted={this.formatted}
+          pastedContent={this.pastedContent}
+        />
+      </SplitTestProvider>,
+      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
+    )
+  }
+}

+ 3 - 0
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</0>",
+  "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",