mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 14:13:43 +01:00
Merge pull request #15415 from overleaf/td-scope-store-and-emitter-fixed
IDE scope store and emitter with fixed PDF URLs GitOrigin-RevId: 9d33bad8a006bb55714878332f78932538dd8921
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { Project } from '../../../../../types/project'
|
||||
import { PermissionsLevel } from '../types/permissions-level'
|
||||
|
||||
export type JoinProjectPayloadProject = Pick<
|
||||
Project,
|
||||
Exclude<keyof Project, ['rootDocId', 'publicAccessLevel']>
|
||||
> & { rootDoc_id?: string; publicAccesLevel?: string }
|
||||
|
||||
export type JoinProjectPayload = {
|
||||
permissionsLevel: PermissionsLevel
|
||||
project: JoinProjectPayloadProject
|
||||
protocolVersion: number
|
||||
publicId: string
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export type Socket = {
|
||||
publicId: string
|
||||
on(event: string, callback: (...data: any[]) => void): void
|
||||
removeListener(event: string, callback: (...data: any[]) => void): void
|
||||
emit(
|
||||
event: string,
|
||||
arg0: any,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
FC,
|
||||
useMemo,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
|
||||
import { IdeProvider } from '@/shared/context/ide-context'
|
||||
import {
|
||||
createIdeEventEmitter,
|
||||
IdeEventEmitter,
|
||||
} from '@/features/ide-react/create-ide-event-emitter'
|
||||
import { JoinProjectPayload } from '@/features/ide-react/connection/join-project-payload'
|
||||
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
|
||||
import { getMockIde } from '@/shared/context/mock/mock-ide'
|
||||
import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter'
|
||||
|
||||
type IdeReactContextValue = {
|
||||
projectId: string
|
||||
eventEmitter: IdeEventEmitter
|
||||
}
|
||||
|
||||
const IdeReactContext = createContext<IdeReactContextValue | null>(null)
|
||||
|
||||
function populateIdeReactScope(store: ReactScopeValueStore) {
|
||||
store.set('sync_tex_error', false)
|
||||
}
|
||||
|
||||
function populateProjectScope(store: ReactScopeValueStore) {
|
||||
store.allowNonExistentPath('project', true)
|
||||
store.set('permissionsLevel', 'readOnly')
|
||||
}
|
||||
|
||||
function createReactScopeValueStore() {
|
||||
const scopeStore = new ReactScopeValueStore()
|
||||
|
||||
// Populate the scope value store with default values that will be used by
|
||||
// nested contexts that refer to scope values. The ideal would be to leave
|
||||
// initialization of store values up to the nested context, which would keep
|
||||
// initialization code together with the context and would only populate
|
||||
// necessary values in the store, but this is simpler for now
|
||||
populateIdeReactScope(scopeStore)
|
||||
populateProjectScope(scopeStore)
|
||||
|
||||
return scopeStore
|
||||
}
|
||||
|
||||
const projectId = window.project_id
|
||||
|
||||
export const IdeReactProvider: FC = ({ children }) => {
|
||||
const [scopeStore] = useState(createReactScopeValueStore)
|
||||
const [eventEmitter] = useState(createIdeEventEmitter)
|
||||
const [scopeEventEmitter] = useState(
|
||||
() => new ReactScopeEventEmitter(eventEmitter)
|
||||
)
|
||||
|
||||
const { socket } = useConnectionContext()
|
||||
|
||||
// Fire project:joined event
|
||||
useEffect(() => {
|
||||
function handleJoinProjectResponse({
|
||||
project,
|
||||
permissionsLevel,
|
||||
}: JoinProjectPayload) {
|
||||
eventEmitter.emit('project:joined', { project, permissionsLevel })
|
||||
}
|
||||
|
||||
socket.on('joinProjectResponse', handleJoinProjectResponse)
|
||||
|
||||
return () => {
|
||||
socket.removeListener('joinProjectResponse', handleJoinProjectResponse)
|
||||
}
|
||||
}, [socket, eventEmitter])
|
||||
|
||||
// Populate scope values when joining project, then fire project:joined event
|
||||
useEffect(() => {
|
||||
function handleJoinProjectResponse({
|
||||
project,
|
||||
permissionsLevel,
|
||||
}: JoinProjectPayload) {
|
||||
scopeStore.set('project', { rootDoc_id: null, ...project })
|
||||
scopeStore.set('permissionsLevel', permissionsLevel)
|
||||
// Make watchers update immediately
|
||||
scopeStore.flushUpdates()
|
||||
eventEmitter.emit('project:joined', { project, permissionsLevel })
|
||||
}
|
||||
|
||||
socket.on('joinProjectResponse', handleJoinProjectResponse)
|
||||
|
||||
return () => {
|
||||
socket.removeListener('joinProjectResponse', handleJoinProjectResponse)
|
||||
}
|
||||
}, [socket, eventEmitter, scopeStore])
|
||||
|
||||
const ide = useMemo(() => {
|
||||
return {
|
||||
...getMockIde(),
|
||||
socket,
|
||||
}
|
||||
}, [socket])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
eventEmitter,
|
||||
projectId,
|
||||
}),
|
||||
[eventEmitter]
|
||||
)
|
||||
|
||||
return (
|
||||
<IdeReactContext.Provider value={value}>
|
||||
<IdeProvider
|
||||
ide={ide}
|
||||
scopeStore={scopeStore}
|
||||
scopeEventEmitter={scopeEventEmitter}
|
||||
>
|
||||
{children}
|
||||
</IdeProvider>
|
||||
</IdeReactContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useIdeReactContext(): IdeReactContextValue {
|
||||
const context = useContext(IdeReactContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useIdeReactContext is only available inside IdeReactProvider'
|
||||
)
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
import { ConnectionProvider } from './connection-context'
|
||||
import { FC } from 'react'
|
||||
import { IdeReactProvider } from '@/features/ide-react/context/ide-react-context'
|
||||
import { ProjectProvider } from '@/shared/context/project-context'
|
||||
import { UserProvider } from '@/shared/context/user-context'
|
||||
|
||||
export const ReactContextRoot: FC = ({ children }) => {
|
||||
return <ConnectionProvider>{children}</ConnectionProvider>
|
||||
return (
|
||||
<ConnectionProvider>
|
||||
<IdeReactProvider>
|
||||
<UserProvider>
|
||||
<ProjectProvider>{children}</ProjectProvider>
|
||||
</UserProvider>
|
||||
</IdeReactProvider>
|
||||
</ConnectionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Emitter } from 'strict-event-emitter'
|
||||
import { Project } from '../../../../types/project'
|
||||
import { PermissionsLevel } from '@/features/ide-react/types/permissions-level'
|
||||
import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
|
||||
import { CursorPosition } from '@/features/ide-react/types/cursor-position'
|
||||
|
||||
export type IdeEvents = {
|
||||
'project:joined': [{ project: Project; permissionsLevel: PermissionsLevel }]
|
||||
|
||||
'editor:gotoOffset': [gotoOffset: number]
|
||||
'editor:gotoLine': [options: GotoLineOptions]
|
||||
'outline-toggled': [isOpen: boolean]
|
||||
'cursor:editor:update': [position: CursorPosition]
|
||||
'cursor:editor:syncToPdf': []
|
||||
'scroll:editor:update': []
|
||||
'comment:start_adding': []
|
||||
}
|
||||
|
||||
export type IdeEventEmitter = Emitter<IdeEvents>
|
||||
|
||||
export function createIdeEventEmitter(): IdeEventEmitter {
|
||||
return new Emitter<IdeEvents>()
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
ScopeEventEmitter,
|
||||
ScopeEventName,
|
||||
} from '../../../../../types/ide/scope-event-emitter'
|
||||
import { Scope } from '../../../../../types/angular/scope'
|
||||
|
||||
export class AngularScopeEventEmitter implements ScopeEventEmitter {
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
constructor(readonly $scope: Scope) {}
|
||||
|
||||
emit(eventName: ScopeEventName, broadcast: boolean, ...detail: unknown[]) {
|
||||
if (broadcast) {
|
||||
this.$scope.$broadcast(eventName, ...detail)
|
||||
} else {
|
||||
this.$scope.$emit(eventName, ...detail)
|
||||
}
|
||||
}
|
||||
|
||||
on(eventName: ScopeEventName, listener: (...args: unknown[]) => void) {
|
||||
return this.$scope.$on(eventName, listener)
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
ScopeEventEmitter,
|
||||
ScopeEventName,
|
||||
} from '../../../../../types/ide/scope-event-emitter'
|
||||
import EventEmitter from 'events'
|
||||
|
||||
export class ReactScopeEventEmitter implements ScopeEventEmitter {
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
constructor(private readonly eventEmitter: EventEmitter) {}
|
||||
|
||||
emit(eventName: ScopeEventName, broadcast: boolean, ...detail: unknown[]) {
|
||||
this.eventEmitter.emit(eventName, ...detail)
|
||||
}
|
||||
|
||||
on(eventName: ScopeEventName, listener: (...args: unknown[]) => void) {
|
||||
// A listener attached via useScopeEventListener expects an event as the
|
||||
// first parameter. We don't have one, so just provide an empty object
|
||||
const wrappedListener = (...detail: unknown[]) => {
|
||||
listener({}, ...detail)
|
||||
}
|
||||
this.eventEmitter.on(eventName, wrappedListener)
|
||||
return () => {
|
||||
this.eventEmitter.off(eventName, wrappedListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { ScopeValueStore } from '../../../../../types/ide/scope-value-store'
|
||||
import { Scope } from '../../../../../types/angular/scope'
|
||||
import _ from 'lodash'
|
||||
|
||||
export class AngularScopeValueStore implements ScopeValueStore {
|
||||
// eslint-disable-next-line no-useless-constructor
|
||||
constructor(readonly $scope: Scope) {}
|
||||
|
||||
get(path: string) {
|
||||
return _.get(this.$scope, path)
|
||||
}
|
||||
|
||||
set(path: string, value: unknown): void {
|
||||
this.$scope.$applyAsync(() => _.set(this.$scope, path, value))
|
||||
}
|
||||
|
||||
watch<T>(
|
||||
path: string,
|
||||
callback: (newValue: T) => void,
|
||||
deep: boolean
|
||||
): () => void {
|
||||
return this.$scope.$watch(
|
||||
path,
|
||||
(newValue: T) => callback(deep ? _.cloneDeep(newValue) : newValue),
|
||||
deep
|
||||
)
|
||||
}
|
||||
}
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
import { ScopeValueStore } from '../../../../../types/ide/scope-value-store'
|
||||
import _ from 'lodash'
|
||||
import customLocalStorage from '../../../infrastructure/local-storage'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
|
||||
const NOT_FOUND = Symbol('not found')
|
||||
|
||||
type Watcher<T> = {
|
||||
removed: boolean
|
||||
callback: (value: T) => void
|
||||
}
|
||||
|
||||
// A value that has been set
|
||||
type ScopeValueStoreValue<T = any> = {
|
||||
value?: T
|
||||
watchers: Watcher<T>[]
|
||||
}
|
||||
|
||||
type WatcherUpdate<T = any> = {
|
||||
path: string
|
||||
value: T
|
||||
watchers: Watcher<T>[]
|
||||
}
|
||||
|
||||
type NonExistentValue = {
|
||||
value: undefined
|
||||
}
|
||||
|
||||
type AllowedNonExistentPath = {
|
||||
path: string
|
||||
deep: boolean
|
||||
}
|
||||
|
||||
type Persister = {
|
||||
localStorageKey: string
|
||||
toPersisted?: (value: unknown) => unknown
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is object {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!('length' in value && typeof value.length === 'number' && value.length > 0)
|
||||
)
|
||||
}
|
||||
|
||||
function ancestorPaths(path: string) {
|
||||
const ancestors: string[] = []
|
||||
let currentPath = path
|
||||
let lastPathSeparatorPos: number
|
||||
while ((lastPathSeparatorPos = currentPath.lastIndexOf('.')) !== -1) {
|
||||
currentPath = currentPath.slice(0, lastPathSeparatorPos)
|
||||
ancestors.push(currentPath)
|
||||
}
|
||||
return ancestors
|
||||
}
|
||||
|
||||
// Store scope values in a simple map
|
||||
export class ReactScopeValueStore implements ScopeValueStore {
|
||||
private readonly items = new Map<string, ScopeValueStoreValue>()
|
||||
private readonly persisters: Map<string, Persister> = new Map()
|
||||
|
||||
private watcherUpdates: WatcherUpdate[] = []
|
||||
private watcherUpdateTimer: number | null = null
|
||||
private allowedNonExistentPaths: AllowedNonExistentPath[] = []
|
||||
|
||||
private nonExistentPathAllowed(path: string) {
|
||||
return this.allowedNonExistentPaths.some(allowedPath => {
|
||||
return (
|
||||
allowedPath.path === path ||
|
||||
(allowedPath.deep && path.startsWith(allowedPath.path + '.'))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Create an item for a path. Attempt to get a value for the item from its
|
||||
// ancestors, if there are any.
|
||||
private findInAncestors(path: string): ScopeValueStoreValue {
|
||||
// Populate value from the nested property ancestors, if possible
|
||||
for (const ancestorPath of ancestorPaths(path)) {
|
||||
const ancestorItem = this.items.get(ancestorPath)
|
||||
if (
|
||||
ancestorItem &&
|
||||
'value' in ancestorItem &&
|
||||
isObject(ancestorItem.value)
|
||||
) {
|
||||
const pathRelativeToAncestor = path.slice(ancestorPath.length + 1)
|
||||
const ancestorValue = _.get(ancestorItem.value, pathRelativeToAncestor)
|
||||
if (ancestorValue !== NOT_FOUND) {
|
||||
return { value: ancestorValue, watchers: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { watchers: [] }
|
||||
}
|
||||
|
||||
private getItem<T>(path: string): ScopeValueStoreValue<T> | NonExistentValue {
|
||||
const item = this.items.get(path) || this.findInAncestors(path)
|
||||
if (!('value' in item)) {
|
||||
if (this.nonExistentPathAllowed(path)) {
|
||||
debugConsole.log(
|
||||
`No value found for key '${path}'. This is allowed because the path is in allowedNonExistentPaths`
|
||||
)
|
||||
return { value: undefined }
|
||||
} else {
|
||||
throw new Error(`No value found for key '${path}'`)
|
||||
}
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
private reassembleObjectValue(path: string, value: Record<string, any>) {
|
||||
const newValue: Record<string, any> = { ...value }
|
||||
const pathPrefix = path + '.'
|
||||
for (const [key, item] of this.items.entries()) {
|
||||
if (key.startsWith(pathPrefix)) {
|
||||
const propName = key.slice(pathPrefix.length)
|
||||
if (propName.indexOf('.') === -1 && 'value' in item) {
|
||||
newValue[propName] = item.value
|
||||
}
|
||||
}
|
||||
}
|
||||
return newValue
|
||||
}
|
||||
|
||||
flushUpdates() {
|
||||
if (this.watcherUpdateTimer) {
|
||||
window.clearTimeout(this.watcherUpdateTimer)
|
||||
this.watcherUpdateTimer = null
|
||||
}
|
||||
// Clone watcherUpdates in case a watcher creates new watcherUpdates
|
||||
const watcherUpdates = [...this.watcherUpdates]
|
||||
this.watcherUpdates = []
|
||||
for (const { value, watchers } of watcherUpdates) {
|
||||
for (const watcher of watchers) {
|
||||
if (!watcher.removed) {
|
||||
watcher.callback.call(null, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleWatcherUpdate<T>(
|
||||
path: string,
|
||||
value: T,
|
||||
watchers: Watcher<T>[]
|
||||
) {
|
||||
// Make a copy of the watchers so that any watcher added before this update
|
||||
// runs is not triggered
|
||||
const update: WatcherUpdate = {
|
||||
value,
|
||||
path,
|
||||
watchers: [...watchers],
|
||||
}
|
||||
this.watcherUpdates.push(update)
|
||||
if (!this.watcherUpdateTimer) {
|
||||
this.watcherUpdateTimer = window.setTimeout(() => {
|
||||
this.watcherUpdateTimer = null
|
||||
this.flushUpdates()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
get<T>(path: string) {
|
||||
return this.getItem<T>(path).value
|
||||
}
|
||||
|
||||
private setValue<T>(path: string, value: T): void {
|
||||
let item = this.items.get(path)
|
||||
if (item === undefined) {
|
||||
item = { value, watchers: [] }
|
||||
this.items.set(path, item)
|
||||
} else if (!('value' in item)) {
|
||||
item = { ...item, value }
|
||||
this.items.set(path, item)
|
||||
} else if (item.value === value) {
|
||||
// Don't update and trigger watchers if the value hasn't changed
|
||||
return
|
||||
} else {
|
||||
item.value = value
|
||||
}
|
||||
this.scheduleWatcherUpdate<T>(path, value, item.watchers)
|
||||
|
||||
// Persist to local storage, if configured to do so
|
||||
const persister = this.persisters.get(path)
|
||||
if (persister) {
|
||||
customLocalStorage.setItem(
|
||||
persister.localStorageKey,
|
||||
persister.toPersisted?.(value) || value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private setValueAndDescendants<T>(path: string, value: T): void {
|
||||
this.setValue(path, value)
|
||||
|
||||
// Set nested values non-recursively, only updating existing items
|
||||
if (isObject(value)) {
|
||||
const pathPrefix = path + '.'
|
||||
for (const [nestedPath, existingItem] of this.items.entries()) {
|
||||
if (nestedPath.startsWith(pathPrefix)) {
|
||||
const newValue = _.get(
|
||||
value,
|
||||
nestedPath.slice(pathPrefix.length),
|
||||
NOT_FOUND
|
||||
)
|
||||
// Only update a nested value if it has changed
|
||||
if (
|
||||
newValue !== NOT_FOUND &&
|
||||
(!('value' in existingItem) || newValue !== existingItem.value)
|
||||
) {
|
||||
this.setValue(nestedPath, newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete nested items corresponding to properties that do not exist in
|
||||
// the new object
|
||||
const pathsToDelete: string[] = []
|
||||
const newPropNames = new Set(Object.keys(value))
|
||||
for (const path of this.items.keys()) {
|
||||
if (path.startsWith(pathPrefix)) {
|
||||
const propName = path.slice(pathPrefix.length).split('.', 1)[0]
|
||||
if (!newPropNames.has(propName)) {
|
||||
pathsToDelete.push(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const path of pathsToDelete) {
|
||||
this.items.delete(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set(path: string, value: unknown): void {
|
||||
this.setValueAndDescendants(path, value)
|
||||
|
||||
// Reassemble ancestors. For example, if the path is x.y.z, x.y and x have
|
||||
// now changed too and must be updated
|
||||
for (const ancestorPath of ancestorPaths(path)) {
|
||||
const ancestorItem = this.items.get(ancestorPath)
|
||||
if (ancestorItem && 'value' in ancestorItem) {
|
||||
ancestorItem.value = this.reassembleObjectValue(
|
||||
ancestorPath,
|
||||
ancestorItem.value
|
||||
)
|
||||
this.scheduleWatcherUpdate(
|
||||
ancestorPath,
|
||||
ancestorItem.value,
|
||||
ancestorItem.watchers
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes in a scope value. The value does not need to exist yet.
|
||||
// Watchers are batched and called asynchronously to avoid chained state
|
||||
// watcherUpdates, which result in warnings from React (see
|
||||
// https://github.com/facebook/react/issues/18178)
|
||||
watch<T>(path: string, callback: Watcher<T>['callback']): () => void {
|
||||
let item = this.items.get(path)
|
||||
if (!item) {
|
||||
item = this.findInAncestors(path)
|
||||
this.items.set(path, item)
|
||||
}
|
||||
const watchers = item.watchers
|
||||
const watcher = { removed: false, callback }
|
||||
item.watchers.push(watcher)
|
||||
|
||||
// Schedule watcher immediately. This is to work around the fact that there
|
||||
// is a delay between getting an initial value and adding a watcher in
|
||||
// useScopeValue, during which the value could change without being
|
||||
// observed
|
||||
if ('value' in item) {
|
||||
this.scheduleWatcherUpdate<T>(path, item.value, [watcher])
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Add a flag to the watcher so that it can be ignored if the watcher is
|
||||
// removed in the interval between observing a change and being called
|
||||
watcher.removed = true
|
||||
_.pull(watchers, watcher)
|
||||
}
|
||||
}
|
||||
|
||||
persisted<Value, PersistedValue>(
|
||||
path: string,
|
||||
fallbackValue: Value,
|
||||
localStorageKey: string,
|
||||
converter?: {
|
||||
toPersisted: (value: Value) => PersistedValue
|
||||
fromPersisted: (persisted: PersistedValue) => Value
|
||||
}
|
||||
) {
|
||||
const persistedValue = customLocalStorage.getItem(
|
||||
localStorageKey
|
||||
) as PersistedValue | null
|
||||
|
||||
let value: Value = fallbackValue
|
||||
if (persistedValue !== null) {
|
||||
value = converter
|
||||
? converter.fromPersisted(persistedValue)
|
||||
: (persistedValue as Value)
|
||||
}
|
||||
this.set(path, value)
|
||||
|
||||
// Don't persist the value until set() is called
|
||||
this.persisters.set(path, {
|
||||
localStorageKey,
|
||||
toPersisted: converter?.toPersisted as Persister['toPersisted'],
|
||||
})
|
||||
}
|
||||
|
||||
allowNonExistentPath(path: string, deep = false) {
|
||||
this.allowedNonExistentPaths.push({ path, deep })
|
||||
}
|
||||
|
||||
// For debugging
|
||||
dump() {
|
||||
const entries = []
|
||||
for (const [path, item] of this.items.entries()) {
|
||||
entries.push({
|
||||
path,
|
||||
value: 'value' in item ? item.value : '[not set]',
|
||||
watcherCount: item.watchers.length,
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type CursorPosition = {
|
||||
row: number
|
||||
column: number
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface GotoLineOptions {
|
||||
gotoLine: number
|
||||
gotoColumn?: number
|
||||
syncToPdf?: boolean
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type PermissionsLevel = 'owner' | 'readAndWrite' | 'readOnly'
|
||||
Reference in New Issue
Block a user