- {ownerName === 'You' ? t('you') : ownerName}
+ {ownerName === 'You' ? t('you') : ownerName}
{project.source === 'token' && (
)}
diff --git a/services/web/frontend/js/features/project-list/components/table/project-list-owner-name.tsx b/services/web/frontend/js/features/project-list/components/table/project-list-owner-name.tsx
index 8f056249ab..faff041fa8 100644
--- a/services/web/frontend/js/features/project-list/components/table/project-list-owner-name.tsx
+++ b/services/web/frontend/js/features/project-list/components/table/project-list-owner-name.tsx
@@ -7,7 +7,7 @@ export const ProjectListOwnerName = memo<{ ownerName: string }>(
const x = ownerName === 'You' ? t('you') : ownerName
- return <> — {t('owned_by_x', { x })}>
+ return — {t('owned_by_x', { x })}
}
)
ProjectListOwnerName.displayName = 'ProjectListOwnerName'
diff --git a/services/web/frontend/js/features/project-list/components/table/project-list-table-row.tsx b/services/web/frontend/js/features/project-list/components/table/project-list-table-row.tsx
index 70a13b4a8c..87e2880022 100644
--- a/services/web/frontend/js/features/project-list/components/table/project-list-table-row.tsx
+++ b/services/web/frontend/js/features/project-list/components/table/project-list-table-row.tsx
@@ -22,7 +22,9 @@ function ProjectListTableRow({ project, selected }: ProjectListTableRowProps) {
- {project.name}{' '}
+
+ {project.name}
+ {' '}
|
diff --git a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx
index 443962cc3c..19b4eb0a8a 100644
--- a/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx
+++ b/services/web/frontend/js/features/project-list/components/table/project-tools/buttons/tags-dropdown.tsx
@@ -91,7 +91,7 @@ function TagsDropdown() {
data-testid="project-tools-more-dropdown-menu"
>
{t('add_to_tag')}
- {sortBy(tags, tag => tag.name?.toLowerCase()).map((tag, index) => (
+ {sortBy(tags, tag => tag.name?.toLowerCase()).map(tag => (
@@ -111,6 +111,7 @@ function TagsDropdown() {
)
}
+ translate="no"
>
diff --git a/services/web/frontend/js/features/project-list/components/tags-list.tsx b/services/web/frontend/js/features/project-list/components/tags-list.tsx
index 8f20f15a83..923da2c115 100644
--- a/services/web/frontend/js/features/project-list/components/tags-list.tsx
+++ b/services/web/frontend/js/features/project-list/components/tags-list.tsx
@@ -12,10 +12,9 @@ import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu
type TagsListProps = {
onTagClick?: () => void
- onEditClick?: () => void
}
-function TagsList({ onTagClick, onEditClick }: TagsListProps) {
+function TagsList({ onTagClick }: TagsListProps) {
const { t } = useTranslation()
const { tags, untaggedProjectsCount, selectedTagId, selectTag } =
useProjectListContext()
diff --git a/services/web/frontend/js/features/project-list/components/title/project-list-title.tsx b/services/web/frontend/js/features/project-list/components/title/project-list-title.tsx
index 97b4fdb457..c562831977 100644
--- a/services/web/frontend/js/features/project-list/components/title/project-list-title.tsx
+++ b/services/web/frontend/js/features/project-list/components/title/project-list-title.tsx
@@ -16,9 +16,11 @@ function ProjectListTitle({
}) {
const { t } = useTranslation()
let message = t('projects')
+ let extraProps = {}
if (selectedTag) {
message = `${selectedTag.name}`
+ extraProps = { translate: 'no' }
} else if (selectedTagId === UNCATEGORIZED_KEY) {
message = t('uncategorized_projects')
} else {
@@ -42,7 +44,12 @@ function ProjectListTitle({
}
return (
- {message}
+
+ {message}
+
)
}
diff --git a/services/web/frontend/js/features/project-list/util/user.ts b/services/web/frontend/js/features/project-list/util/user.ts
index cb63ba3aee..cd2abe07d5 100644
--- a/services/web/frontend/js/features/project-list/util/user.ts
+++ b/services/web/frontend/js/features/project-list/util/user.ts
@@ -1,4 +1,5 @@
import { UserRef } from '../../../../../types/project/dashboard/api'
+import { Subscription } from '../../../../../types/project/dashboard/subscription'
import getMeta from '@/utils/meta'
export function getUserName(user: UserRef) {
@@ -20,3 +21,16 @@ export function getUserName(user: UserRef) {
return 'None'
}
+
+export function getUserSubscriptionState(subscription: Subscription) {
+ if ('subscription' in subscription) {
+ if (subscription.subscription.recurlyStatus?.state) {
+ return subscription.subscription.recurlyStatus.state
+ }
+ if (subscription.subscription.paymentProvider) {
+ return subscription.subscription.paymentProvider.state
+ }
+ }
+
+ return null
+}
diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx
index b79fc85f1c..8a700f090c 100644
--- a/services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx
+++ b/services/web/frontend/js/features/review-panel-new/components/review-panel-current-file.tsx
@@ -33,6 +33,7 @@ import ReviewPanelMoreCommentsButton from './review-panel-more-comments-button'
import useMoreCommments from '../hooks/use-more-comments'
import { Decoration } from '@codemirror/view'
import { debounce } from 'lodash'
+import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
type AggregatedRanges = {
changes: Change[]
@@ -46,6 +47,7 @@ const ReviewPanelCurrentFile: FC = () => {
const threads = useThreadsContext()
const state = useCodeMirrorStateContext()
const [hoveredEntry, setHoveredEntry] = useState(null)
+ const newEditor = useIsNewEditorEnabled()
const hoverTimeout = useRef(0)
const handleEntryEnter = useCallback((id: string) => {
@@ -242,7 +244,8 @@ const ReviewPanelCurrentFile: FC = () => {
const positioningRes = positionItems(
containerRef.current,
previousFocusedItem.current.get(docId),
- docId
+ docId,
+ newEditor
)
onEntriesPositioned()
@@ -254,7 +257,7 @@ const ReviewPanelCurrentFile: FC = () => {
)
}
}
- }, [ranges?.docId, onEntriesPositioned])
+ }, [ranges?.docId, onEntriesPositioned, newEditor])
useEffect(() => {
const timer = window.setTimeout(() => {
diff --git a/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx b/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx
index 7d8b694f68..74405ba276 100644
--- a/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx
+++ b/services/web/frontend/js/features/review-panel-new/components/review-panel.tsx
@@ -17,7 +17,7 @@ const ReviewPanel: FC<{ mini?: boolean }> = ({ mini = false }) => {
[choosenSubView, mini]
)
- const style = useReviewPanelStyles(mini)
+ const style = useReviewPanelStyles()
const className = classnames('review-panel-container', {
'review-panel-mini': mini,
diff --git a/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx b/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx
index d5cf34ef93..0a5c737585 100644
--- a/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx
+++ b/services/web/frontend/js/features/review-panel-new/context/threads-context.tsx
@@ -89,7 +89,7 @@ export const ThreadsProvider: FC = ({ children }) => {
) => {
setData(value => {
if (value) {
- const { submitting, ...thread } = value[threadId] ?? {
+ const { submitting: _submitting, ...thread } = value[threadId] ?? {
messages: [],
}
diff --git a/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts b/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts
index 7e7dda1850..727701ccc3 100644
--- a/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts
+++ b/services/web/frontend/js/features/review-panel-new/hooks/use-review-panel-styles.ts
@@ -1,7 +1,7 @@
import { CSSProperties, useCallback, useEffect, useState } from 'react'
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
-export const useReviewPanelStyles = (mini: boolean) => {
+export const useReviewPanelStyles = () => {
const view = useCodeMirrorViewContext()
const [styles, setStyles] = useState({
diff --git a/services/web/frontend/js/features/review-panel-new/utils/position-items.ts b/services/web/frontend/js/features/review-panel-new/utils/position-items.ts
index 54489310a9..e38e663ab0 100644
--- a/services/web/frontend/js/features/review-panel-new/utils/position-items.ts
+++ b/services/web/frontend/js/features/review-panel-new/utils/position-items.ts
@@ -8,7 +8,8 @@ export const positionItems = debounce(
(
element: HTMLDivElement,
previousFocusedItemIndex: number | undefined,
- docId: string
+ docId: string,
+ newEditor: boolean
) => {
const items = Array.from(
element.querySelectorAll('.review-panel-entry')
@@ -41,7 +42,11 @@ export const positionItems = debounce(
return
}
- const activeItemTop = getTopPosition(activeItem, activeItemIndex === 0)
+ const activeItemTop = getTopPosition(
+ activeItem,
+ activeItemIndex === 0,
+ newEditor
+ )
const positions: [HTMLElement, number][] = []
positions.push([activeItem, activeItemTop])
@@ -51,7 +56,7 @@ export const positionItems = debounce(
for (let i = activeItemIndex - 1; i >= 0; i--) {
const item = items[i]
const height = item.offsetHeight
- let top = getTopPosition(item, i === 0)
+ let top = getTopPosition(item, i === 0, newEditor)
const bottom = top + height
if (bottom > topLimit) {
top = topLimit - height - GAP_BETWEEN_ENTRIES
@@ -65,7 +70,7 @@ export const positionItems = debounce(
for (let i = activeItemIndex + 1; i < items.length; i++) {
const item = items[i]
const height = item.offsetHeight
- let top = getTopPosition(item, false)
+ let top = getTopPosition(item, false, newEditor)
if (top < bottomLimit) {
top = bottomLimit + GAP_BETWEEN_ENTRIES
}
@@ -87,7 +92,16 @@ export const positionItems = debounce(
{ leading: false, trailing: true, maxWait: 1000 }
)
-function getTopPosition(item: HTMLDivElement, isFirstEntry: boolean) {
+function getTopPosition(
+ item: HTMLDivElement,
+ isFirstEntry: boolean,
+ newEditor: boolean
+) {
const offset = isFirstEntry ? 0 : OFFSET_FOR_ENTRIES_ABOVE
+
+ if (newEditor) {
+ return Math.max(offset, Number(item.dataset.top))
+ }
+
return Math.max(COLLAPSED_HEADER_HEIGHT + offset, Number(item.dataset.top))
}
diff --git a/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx b/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx
index f55344a2f2..e94b39c935 100644
--- a/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx
+++ b/services/web/frontend/js/features/settings/components/emails/add-email/country-input.tsx
@@ -60,9 +60,9 @@ function Downshift({ setValue, inputRef }: CountryInputProps) {
},
ref: inputRef,
})}
+ append={}
placeholder={t('country')}
/>
-
{allIntegrationLinkingWidgets.map(
- ({ import: importObject, path }, widgetIndex) => (
+ ({ import: importObject }, widgetIndex) => (
{t('reference_managers')}
{referenceLinkingWidgets.map(
- ({ import: importObject, path }, widgetIndex) => (
+ ({ import: importObject }, widgetIndex) => (
,
google: ,
orcid: ,
+ oidc: ,
}
type SSOLinkingWidgetProps = {
@@ -66,7 +68,7 @@ export function SSOLinkingWidget({
return (
- {providerLogos[providerId]}
+ {providerLogos[providerId] || providerLogos['oidc']}
{title}
diff --git a/services/web/frontend/js/features/settings/components/password-section.tsx b/services/web/frontend/js/features/settings/components/password-section.tsx
index 739636e998..c09ad50562 100644
--- a/services/web/frontend/js/features/settings/components/password-section.tsx
+++ b/services/web/frontend/js/features/settings/components/password-section.tsx
@@ -39,11 +39,7 @@ function CanOnlyLogInThroughSSO() {
return (
,
- ]}
+ i18nKey="you_cant_add_or_change_password_due_to_ldap_or_sso"
/>
)
diff --git a/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx b/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx
index d235bd248b..a2d17734b0 100644
--- a/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx
+++ b/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx
@@ -1,7 +1,7 @@
import { useCallback, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useShareProjectContext } from './share-project-modal'
-import { setProjectAccessLevel } from '../utils/api'
+import { setPublicAccessLevel } from '../utils/api'
import { CopyToClipboard } from '@/shared/components/copy-to-clipboard'
import { useProjectContext } from '@/shared/context/project-context'
import * as eventTracking from '../../../infrastructure/event-tracking'
@@ -43,7 +43,7 @@ export default function LinkSharing() {
project_id: projectId,
})
monitorRequest(() =>
- setProjectAccessLevel(projectId, newPublicAccessLevel)
+ setPublicAccessLevel(projectId, newPublicAccessLevel)
)
.then(() => {
// NOTE: not calling `updateProject` here as it receives data via
diff --git a/services/web/frontend/js/features/share-project-modal/components/select-collaborators.tsx b/services/web/frontend/js/features/share-project-modal/components/select-collaborators.tsx
index 464c5b5368..40c17c44f3 100644
--- a/services/web/frontend/js/features/share-project-modal/components/select-collaborators.tsx
+++ b/services/web/frontend/js/features/share-project-modal/components/select-collaborators.tsx
@@ -116,7 +116,7 @@ export default function SelectCollaborators({
items: filteredOptions,
itemToString: item => (item && item.name) || '',
stateReducer,
- onStateChange: ({ inputValue, type, selectedItem }) => {
+ onStateChange: ({ type, selectedItem }) => {
switch (type) {
// add a selected item on Enter (keypress), click or blur
case useCombobox.stateChangeTypes.InputKeyDownEnter:
diff --git a/services/web/frontend/js/features/share-project-modal/utils/api.js b/services/web/frontend/js/features/share-project-modal/utils/api.js
index d52b6a4857..38b2040f2b 100644
--- a/services/web/frontend/js/features/share-project-modal/utils/api.js
+++ b/services/web/frontend/js/features/share-project-modal/utils/api.js
@@ -47,7 +47,7 @@ export function transferProjectOwnership(projectId, member) {
})
}
-export function setProjectAccessLevel(projectId, publicAccessLevel) {
+export function setPublicAccessLevel(projectId, publicAccessLevel) {
return postJSON(`/project/${projectId}/settings/admin`, {
body: { publicAccessLevel },
})
diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx
index 90a968add6..a65232f94d 100644
--- a/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx
+++ b/services/web/frontend/js/features/source-editor/components/codemirror-search-form.tsx
@@ -36,7 +36,6 @@ import { getStoredSelection, setStoredSelection } from '../extensions/search'
import { debounce } from 'lodash'
import { EditorSelection, EditorState } from '@codemirror/state'
import { sendSearchEvent } from '@/features/event-tracking/search-events'
-import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
import { FullProjectSearchButton } from './full-project-search-button'
const MATCH_COUNT_DEBOUNCE_WAIT = 100 // the amount of ms to wait before counting matches
@@ -82,8 +81,6 @@ const CodeMirrorSearchForm: FC = () => {
const inputRef = useRef(null)
const replaceRef = useRef(null)
- const newEditor = useIsNewEditorEnabled()
-
const handleInputRef = useCallback((node: HTMLInputElement) => {
inputRef.current = node
@@ -443,7 +440,7 @@ const CodeMirrorSearchForm: FC = () => {
- {!newEditor && }
+
{position !== null && (
diff --git a/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx b/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx
index 026c1be078..5cf95763f4 100644
--- a/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx
+++ b/services/web/frontend/js/features/source-editor/components/codemirror-toolbar.tsx
@@ -25,6 +25,7 @@ import useReviewPanelLayout from '@/features/review-panel-new/hooks/use-review-p
import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
import Breadcrumbs from '@/features/ide-redesign/components/breadcrumbs'
import classNames from 'classnames'
+import { useUserSettingsContext } from '@/shared/context/user-settings-context'
export const CodeMirrorToolbar = () => {
const view = useCodeMirrorViewContext()
@@ -41,6 +42,9 @@ const Toolbar = memo(function Toolbar() {
const { t } = useTranslation()
const state = useCodeMirrorStateContext()
const view = useCodeMirrorViewContext()
+ const {
+ userSettings: { breadcrumbs },
+ } = useUserSettingsContext()
const [overflowed, setOverflowed] = useState(false)
@@ -192,7 +196,7 @@ const Toolbar = memo(function Toolbar() {
- {newEditor && }
+ {newEditor && breadcrumbs && }
>
)
diff --git a/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx b/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx
index 698204d89c..be02fdbe3c 100644
--- a/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx
+++ b/services/web/frontend/js/features/source-editor/components/full-project-search-button.tsx
@@ -12,6 +12,8 @@ import Close from '@/shared/components/close'
import useTutorial from '@/shared/hooks/promotions/use-tutorial'
import { useEditorContext } from '@/shared/context/editor-context'
import getMeta from '@/utils/meta'
+import { useIsNewEditorEnabled } from '@/features/ide-redesign/utils/new-editor-utils'
+import { useRailContext } from '@/features/ide-redesign/contexts/rail-context'
const PROMOTION_SIGNUP_CUT_OFF_DATE = new Date('2025-04-22T00:00:00Z')
@@ -19,6 +21,8 @@ export const FullProjectSearchButton = ({ query }: { query: SearchQuery }) => {
const view = useCodeMirrorViewContext()
const { t } = useTranslation()
const { setProjectSearchIsOpen } = useLayoutContext()
+ const newEditor = useIsNewEditorEnabled()
+ const { openTab } = useRailContext()
const ref = useRef (null)
const { inactiveTutorials } = useEditorContext()
@@ -44,14 +48,18 @@ export const FullProjectSearchButton = ({ query }: { query: SearchQuery }) => {
}
const openFullProjectSearch = useCallback(() => {
- setProjectSearchIsOpen(true)
+ if (newEditor) {
+ openTab('full-project-search')
+ } else {
+ setProjectSearchIsOpen(true)
+ }
closeSearchPanel(view)
window.setTimeout(() => {
window.dispatchEvent(
new CustomEvent('editor:full-project-search', { detail: query })
)
}, 200)
- }, [setProjectSearchIsOpen, query, view])
+ }, [setProjectSearchIsOpen, query, view, newEditor, openTab])
const onClick = useCallback(() => {
sendSearchEvent('search-open', {
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts
index 2645e853bd..ab58179586 100644
--- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts
+++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/commands.ts
@@ -45,16 +45,16 @@ const themeGenerators: Record = {
left: true,
right: number === numColumns - 1,
}),
- row: (number: number, numRows: number) => '\\hline',
+ row: () => '\\hline',
multicolumn: () => ({ left: true, right: true }),
lastRow: () => '\\hline',
},
[BorderTheme.BOOKTABS]: {
- column: (number: number, numColumns: number) => ({
+ column: () => ({
left: false,
right: false,
}),
- row: (number: number, numRows: number) => {
+ row: (number: number) => {
if (number === 0) {
return '\\toprule'
}
diff --git a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx
index 51c68872f6..d63ed7b706 100644
--- a/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx
+++ b/services/web/frontend/js/features/source-editor/components/table-generator/toolbar/toolbar-button-menu.tsx
@@ -36,7 +36,7 @@ export const ToolbarButtonMenu: FC<
event.preventDefault()
event.stopPropagation()
}}
- onClick={event => {
+ onClick={() => {
onToggle(!open)
}}
disabled={disabled}
diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx
index b34a61c69d..748a04d7cb 100644
--- a/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx
+++ b/services/web/frontend/js/features/source-editor/components/toolbar/math-dropdown.tsx
@@ -34,7 +34,7 @@ export const MathDropdown = memo(function MathDropdown() {
{
+ onClick={() => {
writefullInstance?.openEquationGenerator()
}}
>
diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx
index 190d2e7c7d..a191b63600 100644
--- a/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx
+++ b/services/web/frontend/js/features/source-editor/components/toolbar/table-dropdown.tsx
@@ -46,7 +46,7 @@ export const TableDropdown = memo(function TableDropdown() {
{
+ onClick={() => {
writefullInstance?.openTableGenerator()
}}
>
diff --git a/services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts b/services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts
index 78d2903825..ccdc8b90e7 100644
--- a/services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/cursor-highlights.ts
@@ -187,7 +187,7 @@ class CursorMarker extends RectangleMarker {
const cursorHighlightsLayer = layer({
above: true,
class: 'ol-cm-cursorHighlightsLayer',
- update: (update, layer) => {
+ update: update => {
return (
update.docChanged ||
update.selectionSet ||
diff --git a/services/web/frontend/js/features/source-editor/extensions/cursor-position.ts b/services/web/frontend/js/features/source-editor/extensions/cursor-position.ts
index efde64f40e..0cd69d8b1f 100644
--- a/services/web/frontend/js/features/source-editor/extensions/cursor-position.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/cursor-position.ts
@@ -42,7 +42,7 @@ export const cursorPosition = ({
// Asynchronously dispatch cursor position when the selection changes and
// provide a little debouncing. Using requestAnimationFrame postpones it
// until the next CM6 DOM update.
- ViewPlugin.define(view => {
+ ViewPlugin.define(() => {
let animationFrameRequest: number | null = null
return {
diff --git a/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts b/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts
index af31353a23..413317ec0a 100644
--- a/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/draw-selection.ts
@@ -71,7 +71,7 @@ const cursorLayer = layer({
updateHasMouseDownEffect(update)
)
},
- mount(dom, view) {
+ mount(dom) {
dom.style.animationDuration = '1200ms'
},
class: 'cm-cursorLayer',
@@ -90,7 +90,7 @@ const selectionLayer = layer({
}
return markers
},
- update(update, dom) {
+ update(update) {
return (
update.docChanged ||
update.selectionSet ||
diff --git a/services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts b/services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts
index 647463d608..49d9b195b9 100644
--- a/services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/empty-line-filler.ts
@@ -9,13 +9,13 @@ import {
import browser from './browser'
class EmptyLineWidget extends WidgetType {
- toDOM(view: EditorView): HTMLElement {
+ toDOM(): HTMLElement {
const element = document.createElement('span')
element.className = 'ol-cm-filler'
return element
}
- eq(widget: EmptyLineWidget) {
+ eq() {
return true
}
}
diff --git a/services/web/frontend/js/features/source-editor/extensions/history-ot.ts b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts
new file mode 100644
index 0000000000..91a58599fb
--- /dev/null
+++ b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts
@@ -0,0 +1,449 @@
+import { Decoration, EditorView, WidgetType } from '@codemirror/view'
+import {
+ EditorState,
+ StateEffect,
+ StateField,
+ Transaction,
+} from '@codemirror/state'
+import {
+ CommentList,
+ EditOperation,
+ TextOperation,
+ TrackingProps,
+ TrackedChangeList,
+} from 'overleaf-editor-core'
+import { DocumentContainer } from '@/features/ide-react/editor/document-container'
+import { HistoryOTShareDoc } from '../../../../../types/share-doc'
+
+export const historyOT = (currentDoc: DocumentContainer) => {
+ const trackedChanges =
+ currentDoc.doc?.getTrackedChanges() ?? new TrackedChangeList([])
+ const positionMapper = new PositionMapper(trackedChanges)
+ return [
+ updateSender,
+ trackChangesUserIdState,
+ shareDocState.init(() => currentDoc?.doc?._doc ?? null),
+ commentsState,
+ trackedChangesState.init(() => ({
+ decorations: buildTrackedChangesDecorations(
+ trackedChanges,
+ positionMapper
+ ),
+ positionMapper,
+ })),
+ trackedChangesTheme,
+ ]
+}
+
+export const shareDocState = StateField.define({
+ create() {
+ return null
+ },
+
+ update(value) {
+ // this state is constant
+ return value
+ },
+})
+
+const trackedChangesTheme = EditorView.baseTheme({
+ '.ol-cm-change-i, .ol-cm-change-highlight-i, .ol-cm-change-focus-i': {
+ backgroundColor: 'rgba(44, 142, 48, 0.30)',
+ },
+ '&light .ol-cm-change-c, &light .ol-cm-change-highlight-c, &light .ol-cm-change-focus-c':
+ {
+ backgroundColor: 'rgba(243, 177, 17, 0.30)',
+ },
+ '&dark .ol-cm-change-c, &dark .ol-cm-change-highlight-c, &dark .ol-cm-change-focus-c':
+ {
+ backgroundColor: 'rgba(194, 93, 11, 0.15)',
+ },
+ '.ol-cm-change': {
+ padding: 'var(--half-leading, 0) 0',
+ },
+ '.ol-cm-change-highlight': {
+ padding: 'var(--half-leading, 0) 0',
+ },
+ '.ol-cm-change-focus': {
+ padding: 'var(--half-leading, 0) 0',
+ },
+ '&light .ol-cm-change-d': {
+ borderLeft: '2px dotted #c5060b',
+ marginLeft: '-1px',
+ },
+ '&dark .ol-cm-change-d': {
+ borderLeft: '2px dotted #c5060b',
+ marginLeft: '-1px',
+ },
+ '&light .ol-cm-change-d-highlight': {
+ borderLeft: '3px solid #c5060b',
+ marginLeft: '-2px',
+ },
+ '&dark .ol-cm-change-d-highlight': {
+ borderLeft: '3px solid #c5060b',
+ marginLeft: '-2px',
+ },
+ '&light .ol-cm-change-d-focus': {
+ borderLeft: '3px solid #B83A33',
+ marginLeft: '-2px',
+ },
+ '&dark .ol-cm-change-d-focus': {
+ borderLeft: '3px solid #B83A33',
+ marginLeft: '-2px',
+ },
+})
+
+export const updateTrackedChangesEffect =
+ StateEffect.define()
+
+const buildTrackedChangesDecorations = (
+ trackedChanges: TrackedChangeList,
+ positionMapper: PositionMapper
+) => {
+ const decorations = []
+ for (const change of trackedChanges.asSorted()) {
+ if (change.tracking.type === 'insert') {
+ decorations.push(
+ Decoration.mark({
+ class: 'ol-cm-change ol-cm-change-i',
+ tracking: change.tracking,
+ }).range(
+ positionMapper.toCM6(change.range.pos),
+ positionMapper.toCM6(change.range.end)
+ )
+ )
+ } else {
+ decorations.push(
+ Decoration.widget({
+ widget: new ChangeDeletedWidget(),
+ side: 1,
+ }).range(positionMapper.toCM6(change.range.pos))
+ )
+ }
+ }
+
+ return Decoration.set(decorations, true)
+}
+
+class ChangeDeletedWidget extends WidgetType {
+ toDOM() {
+ const widget = document.createElement('span')
+ widget.classList.add('ol-cm-change')
+ widget.classList.add('ol-cm-change-d')
+ return widget
+ }
+
+ eq() {
+ return true
+ }
+}
+
+export const trackedChangesState = StateField.define({
+ create() {
+ return {
+ decorations: Decoration.none,
+ positionMapper: new PositionMapper(new TrackedChangeList([])),
+ }
+ },
+
+ update(value, transaction) {
+ if (
+ (transaction.docChanged && !transaction.annotation(Transaction.remote)) ||
+ transaction.effects.some(effect => effect.is(updateTrackedChangesEffect))
+ ) {
+ const shareDoc = transaction.startState.field(shareDocState)
+ if (shareDoc != null) {
+ const trackedChanges = shareDoc.snapshot.getTrackedChanges()
+ const positionMapper = new PositionMapper(trackedChanges)
+ value = {
+ decorations: buildTrackedChangesDecorations(
+ trackedChanges,
+ positionMapper
+ ),
+ positionMapper,
+ }
+ }
+ }
+
+ return value
+ },
+
+ provide(field) {
+ return EditorView.decorations.from(field, value => value.decorations)
+ },
+})
+
+const setTrackChangesUserIdEffect = StateEffect.define()
+
+export const setTrackChangesUserId = (userId: string | null) => {
+ return {
+ effects: setTrackChangesUserIdEffect.of(userId),
+ }
+}
+
+const trackChangesUserIdState = StateField.define({
+ create() {
+ return null
+ },
+
+ update(value, transaction) {
+ for (const effect of transaction.effects) {
+ if (effect.is(setTrackChangesUserIdEffect)) {
+ value = effect.value
+ }
+ }
+ return value
+ },
+})
+
+const updateCommentsEffect = StateEffect.define()
+
+export const updateComments = (comments: CommentList) => {
+ return {
+ effects: updateCommentsEffect.of(comments),
+ }
+}
+
+const buildCommentsDecorations = (comments: CommentList) =>
+ Decoration.set(
+ comments.toArray().flatMap(comment =>
+ comment.ranges.map(range =>
+ Decoration.mark({
+ class: 'tracked-change-comment',
+ id: comment.id,
+ resolved: comment.resolved,
+ }).range(range.pos, range.end)
+ )
+ ),
+ true
+ )
+
+const commentsState = StateField.define({
+ create() {
+ return Decoration.none // TODO: init from snapshot
+ },
+
+ update(value, transaction) {
+ if (transaction.docChanged) {
+ value = value.map(transaction.changes)
+ }
+
+ for (const effect of transaction.effects) {
+ if (effect.is(updateCommentsEffect)) {
+ value = buildCommentsDecorations(effect.value)
+ }
+ }
+
+ return value
+ },
+
+ provide(field) {
+ return EditorView.decorations.from(field)
+ },
+})
+
+export const historyOTOperationEffect = StateEffect.define()
+
+const updateSender = EditorState.transactionExtender.of(tr => {
+ if (!tr.docChanged || tr.annotation(Transaction.remote)) {
+ return {}
+ }
+
+ const trackingUserId = tr.startState.field(trackChangesUserIdState)
+ const positionMapper = tr.startState.field(trackedChangesState).positionMapper
+ const startDoc = tr.startState.doc
+ const opBuilder = new OperationBuilder(
+ positionMapper.toSnapshot(startDoc.length)
+ )
+
+ if (trackingUserId == null) {
+ // Not tracking changes
+ tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
+ // insert
+ if (inserted.length > 0) {
+ const pos = positionMapper.toSnapshot(fromA)
+ opBuilder.insert(pos, inserted.toString())
+ }
+
+ // deletion
+ if (toA > fromA) {
+ const start = positionMapper.toSnapshot(fromA)
+ const end = positionMapper.toSnapshot(toA)
+ opBuilder.delete(start, end - start)
+ }
+ })
+ } else {
+ // Tracking changes
+ const timestamp = new Date()
+ tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
+ // insertion
+ if (inserted.length > 0) {
+ const pos = positionMapper.toSnapshot(fromA)
+ opBuilder.trackedInsert(
+ pos,
+ inserted.toString(),
+ trackingUserId,
+ timestamp
+ )
+ }
+
+ // deletion
+ if (toA > fromA) {
+ const start = positionMapper.toSnapshot(fromA)
+ const end = positionMapper.toSnapshot(toA)
+ opBuilder.trackedDelete(start, end - start, trackingUserId, timestamp)
+ }
+ })
+ }
+
+ const op = opBuilder.finish()
+ const shareDoc = tr.startState.field(shareDocState)
+ if (shareDoc != null) {
+ shareDoc.submitOp([op])
+ }
+
+ return {}
+})
+
+/**
+ * Incrementally builds a TextOperation from a series of inserts and deletes.
+ *
+ * This relies on inserts and deletes being ordered by document position. This
+ * is not clear in the documentation, but has been confirmed by Marijn in
+ * https://discuss.codemirror.net/t/iterators-can-be-hard-to-work-with-for-beginners/3533/10
+ */
+class OperationBuilder {
+ /**
+ * Source document length
+ */
+ private docLength: number
+
+ /**
+ * Position in the source document
+ */
+ private pos: number
+
+ /**
+ * Operation built
+ */
+ private op: TextOperation
+
+ constructor(docLength: number) {
+ this.docLength = docLength
+ this.op = new TextOperation()
+ this.pos = 0
+ }
+
+ insert(pos: number, text: string) {
+ this.retainUntil(pos)
+ this.op.insert(text)
+ }
+
+ delete(pos: number, length: number) {
+ this.retainUntil(pos)
+ this.op.remove(length)
+ this.pos += length
+ }
+
+ trackedInsert(pos: number, text: string, userId: string, timestamp: Date) {
+ this.retainUntil(pos)
+ this.op.insert(text, {
+ tracking: new TrackingProps('insert', userId, timestamp),
+ })
+ }
+
+ trackedDelete(pos: number, length: number, userId: string, timestamp: Date) {
+ this.retainUntil(pos)
+ this.op.retain(length, {
+ tracking: new TrackingProps('delete', userId, timestamp),
+ })
+ this.pos += length
+ }
+
+ retainUntil(pos: number) {
+ if (pos > this.pos) {
+ this.op.retain(pos - this.pos)
+ this.pos = pos
+ } else if (pos < this.pos) {
+ throw Error(
+ `Out of order: position ${pos} comes before current position: ${this.pos}`
+ )
+ }
+ }
+
+ finish() {
+ this.retainUntil(this.docLength)
+ return this.op
+ }
+}
+
+type OffsetTable = { pos: number; map: (pos: number) => number }[]
+
+class PositionMapper {
+ private offsets: {
+ toCM6: OffsetTable
+ toSnapshot: OffsetTable
+ }
+
+ constructor(trackedChanges: TrackedChangeList) {
+ this.offsets = {
+ toCM6: [{ pos: 0, map: pos => pos }],
+ toSnapshot: [{ pos: 0, map: pos => pos }],
+ }
+
+ // Offset of the snapshot pos relative to the CM6 pos
+ let offset = 0
+ for (const change of trackedChanges.asSorted()) {
+ if (change.tracking.type === 'delete') {
+ const deleteLength = change.range.length
+ const deletePos = change.range.pos
+ const oldOffset = offset
+ const newOffset = offset + deleteLength
+ this.offsets.toSnapshot.push({
+ pos: change.range.pos - offset + 1,
+ map: pos => pos + newOffset,
+ })
+ this.offsets.toCM6.push({
+ pos: change.range.pos,
+ map: () => deletePos - oldOffset,
+ })
+ this.offsets.toCM6.push({
+ pos: change.range.pos + deleteLength,
+ map: pos => pos - newOffset,
+ })
+ offset = newOffset
+ }
+ }
+ }
+
+ toCM6(snapshotPos: number) {
+ return this.mapPos(snapshotPos, this.offsets.toCM6)
+ }
+
+ toSnapshot(cm6Pos: number) {
+ return this.mapPos(cm6Pos, this.offsets.toSnapshot)
+ }
+
+ mapPos(pos: number, offsets: OffsetTable) {
+ // Binary search for the offset at the last position before pos
+ let low = 0
+ let high = offsets.length - 1
+ while (low < high) {
+ const middle = Math.ceil((low + high) / 2)
+ const entry = offsets[middle]
+ if (entry.pos < pos) {
+ // This entry could be the right offset, but lower entries are too low
+ // Because we used Math.ceil(), middle is higher than low and the
+ // algorithm progresses.
+ low = middle
+ } else if (entry.pos > pos) {
+ // This entry is too high
+ high = middle - 1
+ } else {
+ // This is the right entry
+ return entry.map(pos)
+ }
+ }
+ return offsets[low].map(pos)
+ }
+}
diff --git a/services/web/frontend/js/features/source-editor/extensions/index.ts b/services/web/frontend/js/features/source-editor/extensions/index.ts
index 0a65739c55..0e19d42fc1 100644
--- a/services/web/frontend/js/features/source-editor/extensions/index.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/index.ts
@@ -50,6 +50,7 @@ import { docName } from './doc-name'
import { fileTreeItemDrop } from './file-tree-item-drop'
import { mathPreview } from './math-preview'
import { ranges } from './ranges'
+import { historyOT } from './history-ot'
import { trackDetachedComments } from './track-detached-comments'
import { reviewTooltip } from './review-tooltip'
@@ -142,7 +143,9 @@ export const createExtensions = (options: Record): Extension[] => [
// NOTE: `emptyLineFiller` needs to be before `trackChanges`,
// so the decorations are added in the correct order.
emptyLineFiller(),
- ranges(),
+ options.currentDoc.currentDocument.getType() === 'history-ot'
+ ? historyOT(options.currentDoc.currentDocument)
+ : ranges(),
trackDetachedComments(options.currentDoc),
visual(options.visual),
mathPreview(options.settings.mathPreview),
diff --git a/services/web/frontend/js/features/source-editor/extensions/keybindings.ts b/services/web/frontend/js/features/source-editor/extensions/keybindings.ts
index 3e67b4b753..01c39d67ba 100644
--- a/services/web/frontend/js/features/source-editor/extensions/keybindings.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/keybindings.ts
@@ -34,17 +34,14 @@ const customiseVimOnce = (_Vim: typeof Vim, _CodeMirror: typeof CodeMirror) => {
// Allow copy via Ctrl-C in insert mode
_Vim.unmap('', 'insert')
- _Vim.defineAction(
- 'insertModeCtrlC',
- (cm: CodeMirror, actionArgs: object, state: any) => {
- if (hasNonEmptySelection(cm)) {
- navigator.clipboard.writeText(cm.getSelection())
- cm.setSelection(cm.getCursor(), cm.getCursor())
- } else {
- _Vim.exitInsertMode(cm)
- }
+ _Vim.defineAction('insertModeCtrlC', (cm: CodeMirror) => {
+ if (hasNonEmptySelection(cm)) {
+ navigator.clipboard.writeText(cm.getSelection())
+ cm.setSelection(cm.getCursor(), cm.getCursor())
+ } else {
+ _Vim.exitInsertMode(cm)
}
- )
+ })
// Overwrite the moveByCharacters command with a decoration-aware version
_Vim.defineMotion(
diff --git a/services/web/frontend/js/features/source-editor/extensions/ranges.ts b/services/web/frontend/js/features/source-editor/extensions/ranges.ts
index 8dc4489d57..7bde7a4adb 100644
--- a/services/web/frontend/js/features/source-editor/extensions/ranges.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/ranges.ts
@@ -68,7 +68,7 @@ export const rangesDataField = StateField.define({
export const ranges = () => [
rangesDataField,
// handle viewportChanged updates
- ViewPlugin.define(view => {
+ ViewPlugin.define(() => {
let timer: number
return {
diff --git a/services/web/frontend/js/features/source-editor/extensions/realtime.ts b/services/web/frontend/js/features/source-editor/extensions/realtime.ts
index 36d9956a76..58cfa8712a 100644
--- a/services/web/frontend/js/features/source-editor/extensions/realtime.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/realtime.ts
@@ -1,10 +1,34 @@
-import { Prec, Transaction, Annotation, ChangeSpec } from '@codemirror/state'
+import {
+ Prec,
+ Transaction,
+ Annotation,
+ ChangeSpec,
+ Text,
+ StateEffect,
+} from '@codemirror/state'
import { EditorView, ViewPlugin } from '@codemirror/view'
import { EventEmitter } from 'events'
import RangesTracker from '@overleaf/ranges-tracker'
-import { ShareDoc } from '../../../../../types/share-doc'
+import {
+ ShareDoc,
+ ShareLatexOTShareDoc,
+ HistoryOTShareDoc,
+} from '../../../../../types/share-doc'
import { debugConsole } from '@/utils/debugging'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
+import {
+ EditOperation,
+ TextOperation,
+ InsertOp,
+ RemoveOp,
+ RetainOp,
+} from 'overleaf-editor-core'
+import {
+ updateTrackedChangesEffect,
+ setTrackChangesUserId,
+ trackedChangesState,
+ shareDocState,
+} from './history-ot'
/*
* Integrate CodeMirror 6 with the real-time system, via ShareJS.
@@ -25,8 +49,10 @@ import { DocumentContainer } from '@/features/ide-react/editor/document-containe
* - frontend/js/features/ide-react/connection/editor-watchdog-manager.js
*/
+type Origin = 'remote' | 'undo' | 'reject' | undefined
+
export type ChangeDescription = {
- origin: 'remote' | 'undo' | 'reject' | undefined
+ origin: Origin
inserted: boolean
removed: boolean
}
@@ -76,15 +102,22 @@ export const realtime = (
return Prec.highest([realtimePlugin, ensureRealtimePlugin])
}
+type OTAdapter = {
+ handleUpdateFromCM(
+ transactions: readonly Transaction[],
+ ranges?: RangesTracker
+ ): void
+ attachShareJs(): void
+}
+
export class EditorFacade extends EventEmitter {
- public shareDoc: ShareDoc | null
+ private otAdapter: OTAdapter | null
public events: EventEmitter
- private maxDocLength?: number
constructor(public view: EditorView) {
super()
this.view = view
- this.shareDoc = null
+ this.otAdapter = null
this.events = new EventEmitter()
}
@@ -118,23 +151,62 @@ export class EditorFacade extends EventEmitter {
this.cmChange({ from: position, to: position + text.length }, origin)
}
+ attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) {
+ this.otAdapter =
+ shareDoc.otType === 'history-ot'
+ ? new HistoryOTAdapter(this, shareDoc, maxDocLength)
+ : new ShareLatexOTAdapter(this, shareDoc, maxDocLength)
+ this.otAdapter.attachShareJs()
+ }
+
+ detachShareJs() {
+ this.otAdapter = null
+ }
+
+ handleUpdateFromCM(
+ transactions: readonly Transaction[],
+ ranges?: RangesTracker
+ ) {
+ if (this.otAdapter == null) {
+ throw new Error('Trying to process updates with no otAdapter')
+ }
+
+ this.otAdapter.handleUpdateFromCM(transactions, ranges)
+ }
+
+ setTrackChangesUserId(userId: string | null) {
+ if (this.otAdapter instanceof HistoryOTAdapter) {
+ this.view.dispatch(setTrackChangesUserId(userId))
+ }
+ }
+}
+
+class ShareLatexOTAdapter {
+ constructor(
+ public editor: EditorFacade,
+ private shareDoc: ShareLatexOTShareDoc,
+ private maxDocLength?: number
+ ) {
+ this.editor = editor
+ this.shareDoc = shareDoc
+ this.maxDocLength = maxDocLength
+ }
+
// Connect to ShareJS, passing changes to the CodeMirror view
// as new transactions.
// This is a broad immitation of helper functions supplied in
// the sharejs library. (See vendor/libs/sharejs, in particular
// the 'attach_ace' helper)
- attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) {
- this.shareDoc = shareDoc
- this.maxDocLength = maxDocLength
-
+ attachShareJs() {
+ const shareDoc = this.shareDoc
const check = () => {
// run in a timeout so it checks the editor content once this update has been applied
window.setTimeout(() => {
- const editorText = this.getValue()
+ const editorText = this.editor.getValue()
const otText = shareDoc.getText()
if (editorText !== otText) {
- shareDoc.emit('error', 'Text does not match in CodeMirror 6')
+ this.shareDoc.emit('error', 'Text does not match in CodeMirror 6')
debugConsole.error('Text does not match!')
debugConsole.error('editor: ' + editorText)
debugConsole.error('ot: ' + otText)
@@ -143,12 +215,12 @@ export class EditorFacade extends EventEmitter {
}
const onInsert = (pos: number, text: string) => {
- this.cmInsert(pos, text, 'remote')
+ this.editor.cmInsert(pos, text, 'remote')
check()
}
const onDelete = (pos: number, text: string) => {
- this.cmDelete(pos, text, 'remote')
+ this.editor.cmDelete(pos, text, 'remote')
check()
}
@@ -161,7 +233,7 @@ export class EditorFacade extends EventEmitter {
shareDoc.removeListener('insert', onInsert)
shareDoc.removeListener('delete', onDelete)
delete shareDoc.detach_cm6
- this.shareDoc = null
+ this.editor.detachShareJs()
}
}
@@ -175,10 +247,6 @@ export class EditorFacade extends EventEmitter {
const trackedDeletesLength =
ranges != null ? ranges.getTrackedDeletesLength() : 0
- if (!shareDoc) {
- throw new Error('Trying to process updates with no shareDoc')
- }
-
for (const transaction of transactions) {
if (transaction.docChanged) {
const origin = chooseOrigin(transaction)
@@ -234,7 +302,7 @@ export class EditorFacade extends EventEmitter {
removed,
}
- this.emit('change', this, changeDescription)
+ this.editor.emit('change', this.editor, changeDescription)
}
)
}
@@ -242,6 +310,154 @@ export class EditorFacade extends EventEmitter {
}
}
+class HistoryOTAdapter {
+ constructor(
+ public editor: EditorFacade,
+ private shareDoc: HistoryOTShareDoc,
+ private maxDocLength?: number
+ ) {
+ this.editor = editor
+ this.shareDoc = shareDoc
+ this.maxDocLength = maxDocLength
+ }
+
+ attachShareJs() {
+ this.checkContent()
+
+ const onRemoteOp = this.onRemoteOp.bind(this)
+ this.shareDoc.on('remoteop', onRemoteOp)
+
+ this.shareDoc.detach_cm6 = () => {
+ this.shareDoc.removeListener('remoteop', onRemoteOp)
+ delete this.shareDoc.detach_cm6
+ this.editor.detachShareJs()
+ }
+ }
+
+ handleUpdateFromCM(transactions: readonly Transaction[]) {
+ for (const transaction of transactions) {
+ if (
+ this.maxDocLength &&
+ transaction.changes.newLength >= this.maxDocLength
+ ) {
+ this.shareDoc.emit(
+ 'error',
+ new Error('document length is greater than maxDocLength')
+ )
+ return
+ }
+
+ const origin = chooseOrigin(transaction)
+ transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
+ this.onCodeMirrorChange(fromA, toA, fromB, toB, inserted, origin)
+ })
+ }
+ }
+
+ onRemoteOp(operations: EditOperation[]) {
+ const positionMapper =
+ this.editor.view.state.field(trackedChangesState).positionMapper
+ const changes: ChangeSpec[] = []
+ let trackedChangesUpdated = false
+ for (const operation of operations) {
+ if (operation instanceof TextOperation) {
+ let cursor = 0
+ for (const op of operation.ops) {
+ if (op instanceof InsertOp) {
+ if (op.tracking?.type !== 'delete') {
+ changes.push({
+ from: positionMapper.toCM6(cursor),
+ insert: op.insertion,
+ })
+ }
+ trackedChangesUpdated = true
+ } else if (op instanceof RemoveOp) {
+ changes.push({
+ from: positionMapper.toCM6(cursor),
+ to: positionMapper.toCM6(cursor + op.length),
+ })
+ cursor += op.length
+ trackedChangesUpdated = true
+ } else if (op instanceof RetainOp) {
+ if (op.tracking != null) {
+ if (op.tracking.type === 'delete') {
+ changes.push({
+ from: positionMapper.toCM6(cursor),
+ to: positionMapper.toCM6(cursor + op.length),
+ })
+ }
+ trackedChangesUpdated = true
+ }
+ cursor += op.length
+ }
+ }
+ }
+
+ const view = this.editor.view
+ const effects: StateEffect[] = []
+ const scrollEffect = view
+ .scrollSnapshot()
+ .map(view.state.changes(changes))
+ if (scrollEffect != null) {
+ effects.push(scrollEffect)
+ }
+ if (trackedChangesUpdated) {
+ const shareDoc = this.editor.view.state.field(shareDocState)
+ if (shareDoc != null) {
+ const trackedChanges = shareDoc.snapshot.getTrackedChanges()
+ effects.push(updateTrackedChangesEffect.of(trackedChanges))
+ }
+ }
+
+ view.dispatch({
+ changes,
+ effects,
+ annotations: [
+ Transaction.remote.of(true),
+ Transaction.addToHistory.of(false),
+ ],
+ })
+ }
+ }
+
+ onCodeMirrorChange(
+ fromA: number,
+ toA: number,
+ fromB: number,
+ toB: number,
+ insertedText: Text,
+ origin: Origin
+ ) {
+ const insertedLength = insertedText.length
+ const removedLength = toA - fromA
+ const inserted = insertedLength > 0
+ const removed = removedLength > 0
+
+ const changeDescription: ChangeDescription = {
+ origin,
+ inserted,
+ removed,
+ }
+
+ this.editor.emit('change', this.editor, changeDescription)
+ }
+
+ checkContent() {
+ // run in a timeout so it checks the editor content once this update has been applied
+ window.setTimeout(() => {
+ const editorText = this.editor.getValue()
+ const otText = this.shareDoc.getText()
+
+ if (editorText !== otText) {
+ this.shareDoc.emit('error', 'Text does not match in CodeMirror 6')
+ debugConsole.error('Text does not match!')
+ debugConsole.error('editor: ' + editorText)
+ debugConsole.error('ot: ' + otText)
+ }
+ }, 0)
+ }
+}
+
export const trackChangesAnnotation = Annotation.define()
const chooseOrigin = (transaction: Transaction) => {
diff --git a/services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts b/services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts
index 20505ed95d..873343c2bc 100644
--- a/services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/vertical-overflow.ts
@@ -188,7 +188,7 @@ class TopPaddingWidget extends WidgetType {
this.height = height
}
- toDOM(view: EditorView): HTMLElement {
+ toDOM(): HTMLElement {
const element = document.createElement('div')
element.style.height = this.height + 'px'
return element
diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts
index 70e508d93e..1826b48719 100644
--- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/begin.ts
@@ -45,6 +45,7 @@ export class BeginWidget extends WidgetType {
return element.getBoundingClientRect()
}
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
buildName(name: HTMLSpanElement, view: EditorView) {
name.textContent = this.environment
}
diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end.ts
index 232399de3b..3ca2439ae1 100644
--- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/end.ts
@@ -7,7 +7,7 @@ export class EndWidget extends WidgetType {
return element
}
- eq(widget: EndWidget) {
+ eq() {
return true
}
diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/environment-line.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/environment-line.ts
index d6ab42503e..d506ac2c38 100644
--- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/environment-line.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/environment-line.ts
@@ -1,4 +1,4 @@
-import { EditorView, WidgetType } from '@codemirror/view'
+import { WidgetType } from '@codemirror/view'
export class EnvironmentLineWidget extends WidgetType {
constructor(
@@ -8,7 +8,7 @@ export class EnvironmentLineWidget extends WidgetType {
super()
}
- toDOM(view: EditorView) {
+ toDOM() {
const element = document.createElement('div')
element.classList.add(`ol-cm-environment-${this.environment}`)
element.classList.add('ol-cm-environment-edge')
diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/table-rendering-error.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/table-rendering-error.ts
index 63ad0a297a..68da3ab058 100644
--- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/table-rendering-error.ts
+++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-widgets/table-rendering-error.ts
@@ -17,6 +17,7 @@ export class TableRenderingErrorWidget extends WidgetType {
const iconType = document.createElement('span')
iconType.classList.add('material-symbols')
iconType.setAttribute('aria-hidden', 'true')
+ iconType.setAttribute('translate', 'no')
iconType.textContent = 'info'
icon.appendChild(iconType)
warning.appendChild(icon)
diff --git a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
index a4e2862e1f..2504afdd0c 100644
--- a/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
+++ b/services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
@@ -185,9 +185,9 @@ function useCodeMirrorScope(view: EditorView) {
if (currentDocument) {
if (trackChanges) {
- currentDocument.track_changes_as = userId || 'anonymous'
+ currentDocument.setTrackChangesUserId(userId ?? 'anonymous')
} else {
- currentDocument.track_changes_as = null
+ currentDocument.setTrackChangesUserId(null)
}
}
}, [userId, currentDocument, trackChanges])
diff --git a/services/web/frontend/js/features/source-editor/languages/latex/latex-indent-service.ts b/services/web/frontend/js/features/source-editor/languages/latex/latex-indent-service.ts
index 08c1798032..d1e8e84bc4 100644
--- a/services/web/frontend/js/features/source-editor/languages/latex/latex-indent-service.ts
+++ b/services/web/frontend/js/features/source-editor/languages/latex/latex-indent-service.ts
@@ -1,7 +1,7 @@
import { indentService } from '@codemirror/language'
export const latexIndentService = () =>
- indentService.of((indentContext, pos) => {
+ indentService.of(indentContext => {
// only use this for insertNewLineAndIndent
if (indentContext.simulatedBreak) {
// match the indentation of the previous line (if present)
diff --git a/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js b/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js
index 0bfaf94d62..c496ce767f 100644
--- a/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js
+++ b/services/web/frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker.js
@@ -2087,7 +2087,10 @@ if (typeof onmessage !== 'undefined') {
}
// export dummy class for testing
export default class LintWorker {
+ // unused vars kept to document the interface
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
postMessage(message) {}
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
addEventListener(eventName, listener) {}
Parse(text) {
return Parse(text)
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/free-plan.tsx b/services/web/frontend/js/features/subscription/components/dashboard/free-plan.tsx
index a8cf7dcf7b..1f9583dd8b 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/free-plan.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/free-plan.tsx
@@ -1,6 +1,5 @@
import { useTranslation, Trans } from 'react-i18next'
import WritefullManagedBundleAddOn from '@/features/subscription/components/dashboard/states/active/change-plan/modals/writefull-bundle-management-modal'
-import RedirectAlerts from './redirect-alerts'
import getMeta from '@/utils/meta'
function FreePlan() {
@@ -9,7 +8,6 @@ function FreePlan() {
return (
<>
-
{
e.preventDefault()
runAsync(postJSON('/user/subscription/account/email'))
}
- if (!personalSubscription || !('payment' in personalSubscription)) return null
-
- const recurlyEmail = personalSubscription.payment.accountEmail
-
- if (!userEmail || recurlyEmail === userEmail) return null
-
return (
<>
|