Repository: motifland/yfs
Branch: main
Commit: 46248a771784
Files: 42
Total size: 50.6 KB
Directory structure:
gitextract_w42u4ncg/
├── .eslintrc
├── .gitignore
├── .prettierignore
├── .prettierrc.json
├── LICENSE
├── README.md
├── examples/
│ └── single-file-sync/
│ ├── .eslintrc.js
│ ├── .gitignore
│ ├── README.md
│ ├── components/
│ │ ├── Editor/
│ │ │ ├── Editor.module.css
│ │ │ ├── GitHub.tsx
│ │ │ ├── index.tsx
│ │ │ ├── options.ts
│ │ │ └── theme.ts
│ │ ├── Footer/
│ │ │ ├── Footer.module.css
│ │ │ └── index.tsx
│ │ ├── Head/
│ │ │ └── index.tsx
│ │ ├── Header/
│ │ │ ├── Header.module.css
│ │ │ └── index.tsx
│ │ └── Navbar/
│ │ ├── Navbar.module.css
│ │ └── index.tsx
│ ├── lib/
│ │ ├── constants.ts
│ │ ├── use-interval.ts
│ │ ├── y-config.ts
│ │ └── y-monaco.ts
│ ├── next-env.d.ts
│ ├── next.config.js
│ ├── package.json
│ ├── pages/
│ │ ├── _app.js
│ │ └── index.tsx
│ ├── styles/
│ │ ├── Shared.module.css
│ │ ├── globals.css
│ │ └── yjs.css
│ ├── tsconfig.json
│ └── types.d.ts
├── package.json
├── src/
│ ├── cache.ts
│ ├── constants.ts
│ ├── helpers.ts
│ ├── index.ts
│ └── yjs.ts
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintrc
================================================
// next is loading eslintrc from root directory, adding this to avoid eslint rules been overridden
{}
================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
.vscode
================================================
FILE: .prettierignore
================================================
node_modules
.next
build
dist
*.tsbuildinfo
*.gitignore
*.svg
*.lock
*.npmignore
*.sql
*.png
*.jpg
*.jpeg
*.gif
*.ico
*.sh
Dockerfile
Dockerfile.*
.env
.env.*
LICENSE
*.log
.DS_Store
.dockerignore
*.patch
*.toml
*.prisma
================================================
FILE: .prettierrc.json
================================================
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false
}
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2022 Motif
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# YFS
Synchronize text files between the browser and the file system using the
[File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API)
and [Yjs](https://yjs.dev/).
Blog post explaining the underlying concepts: [Syncing text files between browser and disk using Yjs and the File System Access API](https://motif.land/blog/syncing-text-files-using-yjs-and-the-file-system-access-api).
## Installation
To get started, install the `@yfs/react` package via npm or yarn:
```shell
# npm
npm install @yfs/react
# Yarn
yarn add @yfs/react
```
## Usage
Example:
```jsx
import React, { useState } from 'react'
import * as Y from 'yjs'
import useYFS from '@yfs/react'
function Editor () {
const { setRootDirectory, directoryName, syncDoc } = useYFS()
const [doc] = useState(new Y.Doc())
return (
{/* Editor code... */}
)
}
```
## Authors
This library is created by the team behind [Motif](https://motif.land)
([@motifland](https://twitter.com/motifland)).
- Michael Fester ([@michaelfester](https://twitter.com/michaelfester))
It is based on the great work by [Kevin Jahns](https://twitter.com/kevin_jahns)
on [Yjs](https://yjs.dev/).
## License
MIT
================================================
FILE: examples/single-file-sync/.eslintrc.js
================================================
/* eslint-env node */
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: ['eslint:recommended', 'plugin:react/recommended', 'next'],
ignorePatterns: ['public/**/*'],
parserOptions: {
ecmaVersion: 12,
sourceType: 'module',
},
plugins: ['react', 'react-hooks'],
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': [
'error',
{ additionalHooks: '(useDeepCompareEffect)' },
],
'@next/next/no-img-element': 'off',
},
settings: {
react: {
version: 'detect',
},
},
overrides: [
{
files: ['*.ts', '*.tsx'],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: ['plugin:@typescript-eslint/recommended'],
rules: {
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
},
},
],
}
================================================
FILE: examples/single-file-sync/.gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
================================================
FILE: examples/single-file-sync/README.md
================================================
This is a sample app showcasing a browser-based editor
([Monaco](https://microsoft.github.io/monaco-editor/)) syncing its content with
the file system. It is using the
[File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API),
in conjunction with the [Yjs](https://yjs.dev/) CRDT implementation.
## Getting Started
First, install the dependencies:
```bash
yarn
```
Then, run the development server:
```bash
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the
result.
================================================
FILE: examples/single-file-sync/components/Editor/Editor.module.css
================================================
.editor {
width: 100%;
height: 100%;
}
================================================
FILE: examples/single-file-sync/components/Editor/GitHub.tsx
================================================
import * as React from 'react'
const GitHub = (props: any) => {
return (
)
}
export default GitHub
================================================
FILE: examples/single-file-sync/components/Editor/index.tsx
================================================
import React, { useCallback } from 'react'
import MonacoEditor from '@monaco-editor/react'
import theme from './theme'
import options from './options'
export default function Editor ({
onDidMount,
}: {
onDidMount: (monaco: any, editor: any) => void
}) {
const onEditorDidMount = useCallback(
(editor: any, monaco: any) => {
monaco.editor.defineTheme('custom', theme)
monaco.editor.setTheme('custom')
onDidMount(monaco, editor)
},
[onDidMount],
)
return (
>}
options={options}
/>
)
}
================================================
FILE: examples/single-file-sync/components/Editor/options.ts
================================================
const options = {
codeLens: false,
fontSize: 14,
lineHeight: 24,
lineNumbers: 'off',
padding: {
top: 30,
bottom: 30
},
selectionHighlight: false,
wordWrap: 'on',
folding: false,
fontFamily:
'Menlo, ui-monospace, SFMono-Regular, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
hideCursorInOverviewRuler: true,
glyphMargin: false,
lightbulb: { enabled: false },
lineDecorationsWidth: 20,
minimap: { enabled: false },
renderLineHighlight: 'none',
roundedSelection: true,
scrollbar: {
alwaysConsumeMouseWheel: false,
horizontal: 'hidden',
vertical: 'hidden',
useShadows: false,
verticalHasArrows: false,
verticalScrollbarSize: 0
},
scrollBeyondLastLine: false,
smoothScrolling: true,
snippetSuggestions: 'none',
suggest: {
showIcons: false
},
tabCompletion: 'off',
tabSize: 2,
wordBasedSuggestions: false,
wrappingStrategy: 'advanced'
}
export default options
================================================
FILE: examples/single-file-sync/components/Editor/theme.ts
================================================
const theme = {
base: 'vs',
inherit: true,
rules: [
{ token: '', foreground: '0F172A' },
{ token: 'invalid', foreground: '0F172A' },
{ token: 'emphasis', fontStyle: 'italic' },
{ token: 'strong', fontStyle: 'bold' },
{ token: 'variable', foreground: '5c6773' },
{ token: 'variable.predefined', foreground: '5c6773' },
{ token: 'constant', foreground: '854D0E' },
{ token: 'comment', foreground: 'abb0b6', fontStyle: 'italic' },
{ token: 'number', foreground: '854D0E' },
{ token: 'number.hex', foreground: '854D0E' },
{ token: 'regexp', foreground: '4dbf99' },
{ token: 'annotation', foreground: '701A75' },
{ token: 'type', foreground: '701A75' },
{ token: 'delimiter', foreground: '5c6773' },
{ token: 'delimiter.html', foreground: '5c6773' },
{ token: 'delimiter.xml', foreground: '5c6773' },
{ token: 'tag', foreground: '701A75' },
{ token: 'tag.id.jade', foreground: 'C026D3' },
{ token: 'tag.class.jade', foreground: 'C026D3' },
{ token: 'meta.scss', foreground: 'C026D3' },
{ token: 'metatag', foreground: 'C026D3' },
{ token: 'metatag.content.html', foreground: '0E7490' },
{ token: 'metatag.html', foreground: 'C026D3' },
{ token: 'metatag.xml', foreground: 'C026D3' },
{ token: 'metatag.php', fontStyle: 'bold' },
{ token: 'key', foreground: '701A75' },
{ token: 'string.key.json', foreground: '701A75' },
{ token: 'string.value.json', foreground: '0E7490' },
{ token: 'attribute.name', foreground: '854D0E' },
{ token: 'attribute.value', foreground: '0451A5' },
{ token: 'attribute.value.number', foreground: 'abb0b6' },
{ token: 'attribute.value.unit', foreground: '0E7490' },
{ token: 'attribute.value.html', foreground: '0E7490' },
{ token: 'attribute.value.xml', foreground: '0E7490' },
{ token: 'string', foreground: '0F172A' },
{ token: 'string.html', foreground: '0E7490' },
{ token: 'string.sql', foreground: '0E7490' },
{ token: 'string.yaml', foreground: '0E7490' },
{ token: 'string.js', foreground: '0E7490' },
{ token: 'string.mdx', foreground: '0E7490' },
{ token: 'string.link', foreground: '0369A1' },
{ token: 'string.heading', fontStyle: 'bold' },
{ token: 'string.target', foreground: 'A1A1AA' },
{ token: 'string.codedelimiter.mdx', foreground: 'A1A1AA' },
{ token: 'keyword', foreground: '854d0e' },
{ token: 'keyword.json', foreground: '854d0e' },
{ token: 'keyword.flow', foreground: '854d0e' },
{ token: 'keyword.flow.scss', foreground: '854d0e' },
{ token: 'operator.scss', foreground: '666666' },
{ token: 'operator.sql', foreground: '778899' },
{ token: 'operator.swift', foreground: '666666' },
{ token: 'predefined.sql', foreground: 'FF00FF' }
],
// Cf. https://github.com/Microsoft/monaco-editor/blob/main/test/playground.generated/customizing-the-appearence-exposed-colors.html
colors: {
'editor.background': '#FFFFFF',
'editor.foreground': '#1c1917',
'editorIndentGuide.background': '#E4E4E7',
'editorIndentGuide.activeBackground': '#A1A1AA',
'editorCursor.foreground': '#51A7F8',
'editor.selectionBackground': '#CFDFEC',
'editor.inactiveSelectionBackground': '#F5F5F5',
'editorLineNumber.foreground': '#D1D5DB'
}
}
export default theme
================================================
FILE: examples/single-file-sync/components/Footer/Footer.module.css
================================================
.syncFooter {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
align-self: flex-start;
padding: 0 6px;
font-size: var(--font-size-sm);
color: var(--text-light);
}
.syncFooter p {
margin: 0;
padding: 0;
}
.dot {
width: 8px;
height: 8px;
border-radius: 9999px;
}
.dotBlue {
background-color: #15e3ff;
}
.dotOrange {
background-color: #f7c444;
}
================================================
FILE: examples/single-file-sync/components/Footer/index.tsx
================================================
import React from 'react'
import styles from './Footer.module.css'
import sharedStyles from '../../styles/Shared.module.css'
export default function Footer ({
directoryName,
isWritePermissionGranted,
unsetRootDirectory,
grantWritePermission,
}: {
directoryName: string | undefined
isWritePermissionGranted: boolean
unsetRootDirectory: () => void
grantWritePermission: () => void
}) {
if (!directoryName) {
return <>>
}
return (
{isWritePermissionGranted && (
<>
Syncing with folder{' '}
{directoryName}.
Disconnect
>
)}
{!isWritePermissionGranted && (
<>
Syncing with folder{' '}
{directoryName} is
paused.
Grant permissions
>
)}
)
}
================================================
FILE: examples/single-file-sync/components/Head/index.tsx
================================================
import React from 'react'
import NextHead from 'next/head'
export default function Head () {
return (
File Sync Demo
)
}
================================================
FILE: examples/single-file-sync/components/Header/Header.module.css
================================================
.cta {
display: inline-block;
color: white;
background-color: var(--button-dark);
outline: 0 !important;
outline-offset: 2px;
box-shadow: none;
font-weight: 500;
font-size: var(--font-size-base);
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
-webkit-appearance: button;
border-style: solid;
border-width: 0;
transition-duration: 150ms;
}
.cta:hover {
opacity: 0.9;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
}
.warning {
color: var(--text-warning);
text-align: center;
}
================================================
FILE: examples/single-file-sync/components/Header/index.tsx
================================================
import React from 'react'
import styles from './Header.module.css'
export default function Header ({
isFSAPISupported,
directoryName,
setRootDirectory,
}: {
isFSAPISupported: boolean
directoryName: string | undefined
setRootDirectory: (writable: boolean) => void
}) {
return (
{isFSAPISupported && !directoryName && (
)}
{!isFSAPISupported && (
The File System Access API is currently not supported in this browser.
)}
)
}
================================================
FILE: examples/single-file-sync/components/Navbar/Navbar.module.css
================================================
.nav {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
width: 100%;
padding: 1.5rem;
}
.nav div:first-child {
flex: 1;
display: flex;
gap: 5px;
flex-direction: column;
}
.nav div:first-child h1,
.nav div:first-child p {
margin: 0;
}
.nav div:first-child h1 {
font-size: 18px;
color: var(--text-dark);
}
.nav div:first-child p {
font-size: var(--font-size-sm);
color: var(--text-light);
}
.nav div:last-child {
display: flex;
gap: 25px;
flex-direction: row;
align-items: center;
}
.nav div:last-child a {
font-size: var(--font-size-sm);
color: var(--text-dark);
}
================================================
FILE: examples/single-file-sync/components/Navbar/index.tsx
================================================
import React from 'react'
import GitHub from 'components/Editor/GitHub'
import styles from './Navbar.module.css'
import sharedStyles from '../../styles/Shared.module.css'
export default function Navbar () {
return (
)
}
================================================
FILE: examples/single-file-sync/lib/constants.ts
================================================
export const YDOC_UPDATE_ORIGIN_CURRENT_EDITOR =
'YDOC_UPDATE_ORIGIN_CURRENT_EDITOR'
export const ROOM_ID = 'yfs-sample-room'
export const TEST_FILE_NAME = 'yfs-test.md'
export const WEBRTC_SIGNALING_SERVERS = [
'wss://motif-signaling.fly.dev/',
]
================================================
FILE: examples/single-file-sync/lib/use-interval.ts
================================================
import { useEffect, useRef } from 'react'
export const useInterval = (callback: () => void, delay: number | null) => {
const savedCallback = useRef()
useEffect(() => {
savedCallback.current = callback
}, [callback])
useEffect(() => {
const tick = () => {
savedCallback.current()
}
if (delay !== null) {
const id = setInterval(tick, delay)
return () => clearInterval(id)
}
}, [delay])
}
================================================
FILE: examples/single-file-sync/lib/y-config.ts
================================================
import * as Y from 'yjs'
import * as awarenessProtocol from 'y-protocols/awareness.js'
import { WebrtcProvider } from 'y-webrtc'
import { IndexeddbPersistence } from 'y-indexeddb'
import * as mutex from 'lib0/mutex.js'
import { MonacoBinding } from './y-monaco'
import { ROOM_ID, WEBRTC_SIGNALING_SERVERS } from './constants'
export const YDOC_UPDATE_ORIGIN_CURRENT_EDITOR =
'YDOC_UPDATE_ORIGIN_CURRENT_EDITOR'
export const YDOC_UPDATE_ORIGIN_PROGRAMMATIC = 'YDOC_UPDATE_ORIGIN_PROGRAMMATIC'
export class YConfig {
roomId: string
monaco: any
model: any
editor: any
userId: number
doc: Y.Doc | undefined
initialEncodedYDoc: string | undefined
awareness: awarenessProtocol.Awareness | undefined
webRTCProvider: WebrtcProvider | undefined
indexedDBPersistence: IndexeddbPersistence | undefined
monacoBinding: MonacoBinding | undefined
mux: mutex.mutex
updateTimeoutId: ReturnType | null
constructor (
monaco: any,
editor: any,
userId: number
) {
this.roomId = ROOM_ID
// this.onNonProgrammaticDocUpdateImmediate = onNonProgrammaticDocUpdateImmediate
// this.onLocalEditorUpdateDebounced = onLocalEditorUpdateDebounced
this.monaco = monaco
this.editor = editor
this.model = editor.getModel()
this.userId = userId
this.mux = mutex.createMutex()
this.updateTimeoutId = null
this.initYDoc()
}
initYDoc (): void {
this.doc = new Y.Doc()
this.awareness = new awarenessProtocol.Awareness(this.doc)
this.webRTCProvider = new WebrtcProvider(this.roomId, this.doc, {
signaling: WEBRTC_SIGNALING_SERVERS,
password: null,
awareness: this.awareness,
maxConns: 50 + Math.floor(Math.random() * 15),
filterBcConns: true,
peerOpts: {}
})
this.monacoBinding = new MonacoBinding(
this.monaco,
this.doc.getText(),
this.model,
this.editor,
this.awareness,
this.userId
)
this.indexedDBPersistence = new IndexeddbPersistence(
'y-indexeddb',
this.doc
)
if (!this.webRTCProvider.connected) {
this.webRTCProvider.connect()
}
}
destroy (): void {
this.awareness?.destroy()
this.monacoBinding?.destroy()
this.webRTCProvider?.disconnect()
this.webRTCProvider?.destroy()
this.doc?.destroy()
}
}
================================================
FILE: examples/single-file-sync/lib/y-monaco.ts
================================================
import * as Y from 'yjs'
import * as error from 'lib0/error.js'
import { createMutex, mutex } from 'lib0/mutex.js'
import * as awarenessProtocol from 'y-protocols/awareness.js'
import { YDOC_UPDATE_ORIGIN_CURRENT_EDITOR } from './constants'
type Direction = any
type Selection = {
start: Y.RelativePosition
end: Y.RelativePosition
direction: Direction
}
class RelativeSelection {
start: Y.RelativePosition
end: Y.RelativePosition
direction: Direction
constructor (
start: Y.RelativePosition,
end: Y.RelativePosition,
direction: Direction,
) {
this.start = start
this.end = end
this.direction = direction
}
}
const createRelativeSelection = (
editor: any,
monacoModel: any,
type: Y.AbstractType,
) => {
const sel = editor.getSelection()
if (sel !== null) {
const startPos = sel.getStartPosition()
const endPos = sel.getEndPosition()
const start = Y.createRelativePositionFromTypeIndex(
type,
monacoModel.getOffsetAt(startPos),
)
const end = Y.createRelativePositionFromTypeIndex(
type,
monacoModel.getOffsetAt(endPos),
)
return new RelativeSelection(start, end, sel.getDirection())
}
return null
}
const createMonacoSelectionFromRelativeSelection = (
monaco: any,
editor: any,
type: Y.AbstractType,
relSel: Selection,
doc: Y.Doc,
) => {
const start = Y.createAbsolutePositionFromRelativePosition(relSel.start, doc)
const end = Y.createAbsolutePositionFromRelativePosition(relSel.end, doc)
if (
start !== null &&
end !== null &&
start.type === type &&
end.type === type
) {
const model = editor.getModel()
if (!model) {
return null
}
const startPos = model.getPositionAt(start.index)
const endPos = model.getPositionAt(end.index)
return monaco.Selection.createWithDirection(
startPos.lineNumber,
startPos.column,
endPos.lineNumber,
endPos.column,
relSel.direction,
)
}
return null
}
export class MonacoBinding {
doc: Y.Doc
ytext: Y.Text
monacoModel: any
editor: any
mux: mutex
color: number
awareness: awarenessProtocol.Awareness | undefined
_savedSelections: Map
_decorations: any
_rerenderDecorations: any
_monacoChangeHandler: any
_beforeTransaction: () => void
_ytextObserver: (event: Y.YTextEvent) => void
constructor (
monaco: any,
ytext: Y.Text,
monacoModel: any,
editor: any,
awareness: awarenessProtocol.Awareness,
userId: number,
) {
this.doc = ytext.doc as Y.Doc
this.ytext = ytext
this.monacoModel = monacoModel
this.editor = editor
this.mux = createMutex()
this._savedSelections = new Map()
this.color = userId % 8
this._beforeTransaction = () => {
this.mux(() => {
this._savedSelections = new Map()
if (editor.getModel() === monacoModel) {
const rsel = createRelativeSelection(editor, monacoModel, ytext)
if (rsel !== null) {
this._savedSelections.set(editor, rsel)
}
}
})
}
this.doc.on('beforeAllTransactions', this._beforeTransaction)
this._decorations = new Map()
this._rerenderDecorations = () => {
if (awareness && editor.getModel() === monacoModel) {
// render decorations
const currentDecorations = this._decorations.get(editor) || []
const newDecorations: any = []
awareness.getStates().forEach((state, clientID) => {
if (
clientID !== this.doc.clientID &&
state.selection != null &&
state.selection.anchor != null &&
state.selection.head != null
) {
const anchorAbs = Y.createAbsolutePositionFromRelativePosition(
state.selection.anchor,
this.doc,
)
const headAbs = Y.createAbsolutePositionFromRelativePosition(
state.selection.head,
this.doc,
)
if (
anchorAbs !== null &&
headAbs !== null &&
anchorAbs.type === ytext &&
headAbs.type === ytext
) {
let start, end, afterContentClassName, beforeContentClassName
if (anchorAbs.index < headAbs.index) {
start = monacoModel.getPositionAt(anchorAbs.index)
end = monacoModel.getPositionAt(headAbs.index)
afterContentClassName = `ySelectionHead yBorder-${this.color}`
beforeContentClassName = null
} else {
start = monacoModel.getPositionAt(headAbs.index)
end = monacoModel.getPositionAt(anchorAbs.index)
afterContentClassName = null
beforeContentClassName = `ySelectionHead yBorder-${this.color}`
}
newDecorations.push({
range: new monaco.Range(
start.lineNumber,
start.column,
end.lineNumber,
end.column,
),
options: {
className: `ySelection-${this.color}`,
afterContentClassName,
beforeContentClassName,
},
})
}
}
})
this._decorations.set(
editor,
editor.deltaDecorations(currentDecorations, newDecorations),
)
} else {
// ignore decorations
this._decorations.delete(editor)
}
}
this._ytextObserver = event => {
this.mux(() => {
let index = 0
event.delta.forEach(op => {
if (op.retain !== undefined) {
index += op.retain
} else if (op.insert !== undefined) {
const pos = monacoModel.getPositionAt(index)
const range = new monaco.Selection(
pos.lineNumber,
pos.column,
pos.lineNumber,
pos.column,
)
monacoModel.applyEdits([{ range, text: op.insert as string }])
index += (op.insert as any).length
} else if (op.delete !== undefined) {
const pos = monacoModel.getPositionAt(index)
const endPos = monacoModel.getPositionAt(index + op.delete)
const range = new monaco.Selection(
pos.lineNumber,
pos.column,
endPos.lineNumber,
endPos.column,
)
monacoModel.applyEdits([{ range, text: '' }])
} else {
throw error.unexpectedCase()
}
})
this._savedSelections.forEach((rsel, editor) => {
const sel = createMonacoSelectionFromRelativeSelection(
monaco,
editor,
ytext,
rsel,
this.doc,
)
if (sel !== null) {
editor.setSelection(sel)
}
})
})
this._rerenderDecorations()
}
ytext.observe(this._ytextObserver)
monacoModel.setValue(ytext.toString())
this._monacoChangeHandler = monacoModel.onDidChangeContent((event: any) => {
// Apply changes from right to left
this.mux(() => {
this.doc.transact(() => {
event.changes
.sort(
(change1: any, change2: any) =>
change2.rangeOffset - change1.rangeOffset,
)
.forEach((change: any) => {
ytext.delete(change.rangeOffset, change.rangeLength)
ytext.insert(change.rangeOffset, change.text)
})
}, YDOC_UPDATE_ORIGIN_CURRENT_EDITOR)
})
})
monacoModel.onWillDispose(() => {
this.destroy()
})
if (awareness) {
editor.onDidChangeCursorSelection(() => {
if (editor.getModel() === monacoModel) {
const sel = editor.getSelection()
if (sel === null) {
return
}
let anchor = monacoModel.getOffsetAt(sel.getStartPosition())
let head = monacoModel.getOffsetAt(sel.getEndPosition())
if (sel.getDirection() === monaco.SelectionDirection.RTL) {
const tmp = anchor
anchor = head
head = tmp
}
awareness.setLocalStateField('selection', {
anchor: Y.createRelativePositionFromTypeIndex(ytext, anchor),
head: Y.createRelativePositionFromTypeIndex(ytext, head),
})
}
})
awareness.on('change', this._rerenderDecorations)
this.awareness = awareness
}
}
destroy (): void {
this._monacoChangeHandler.dispose()
this.ytext.unobserve(this._ytextObserver)
this.doc.off('beforeAllTransactions', this._beforeTransaction)
if (this.awareness) {
this.awareness.off('change', this._rerenderDecorations)
}
}
}
================================================
FILE: examples/single-file-sync/next-env.d.ts
================================================
///
///
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
================================================
FILE: examples/single-file-sync/next.config.js
================================================
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}
module.exports = nextConfig
================================================
FILE: examples/single-file-sync/package.json
================================================
{
"name": "yfs-single-file-sync-example",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@monaco-editor/react": "^4.4.4",
"@yfs/react": "^0.0.15",
"lib0": "^0.2.51",
"next": "12.1.5",
"react": "18.1.0",
"react-dom": "18.1.0",
"y-indexeddb": "^9.0.7",
"y-webrtc": "^10.2.3",
"yjs": "^13.5.35"
},
"devDependencies": {
"@types/node": "^17.0.30",
"@types/react": "^18.0.8",
"@typescript-eslint/eslint-plugin": "^5.22.0",
"@typescript-eslint/parser": "^5.22.0",
"eslint": "8.14.0",
"eslint-config-next": "12.1.5",
"typescript": "^4.6.4"
}
}
================================================
FILE: examples/single-file-sync/pages/_app.js
================================================
import '../styles/globals.css'
import '../styles/yjs.css'
function MyApp({ Component, pageProps }) {
return
}
export default MyApp
================================================
FILE: examples/single-file-sync/pages/index.tsx
================================================
import React, { useCallback, useRef } from 'react'
import useFileSync from '@yfs/react'
import Editor from 'components/Editor'
import Head from 'components/Head'
import Navbar from 'components/Navbar'
import Footer from 'components/Footer'
import Header from 'components/Header'
import { useInterval } from 'lib/use-interval'
import styles from '../styles/Shared.module.css'
import { YConfig } from 'lib/y-config'
import { TEST_FILE_NAME } from 'lib/constants'
export default function Home () {
const config = useRef(undefined)
const {
isSupported,
setRootDirectory,
unsetRootDirectory,
grantWritePermission,
directoryName,
isWritePermissionGranted,
syncDoc
} = useFileSync()
const onEditorDidMount = useCallback((monaco: any, editor: any) => {
config.current?.destroy()
config.current = new YConfig(monaco, editor, Math.floor(Math.random() * 8))
}, [])
const sync = useCallback(() => {
if (!config.current?.doc) {
return
}
syncDoc(TEST_FILE_NAME, config.current.doc)
}, [syncDoc])
useInterval(sync, isWritePermissionGranted ? 10000 : null)
return (
)
}
================================================
FILE: examples/single-file-sync/styles/Shared.module.css
================================================
.container {
min-height: 100vh;
padding: 2rem 0;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
color: var(--text-dark);
}
.container {
padding: 0;
}
.subtleLink {
text-decoration: underline dotted var(--text-extralight);
cursor: pointer;
}
.subtleLink:hover {
opacity: 0.9;
}
.main {
position: relative;
padding: 0 1.5rem 2rem 1.5rem;
width: 100%;
max-width: 980px;
margin-top: 50px;
display: flex;
gap: 10px;
flex-direction: column;
align-items: center;
}
.editorContainer {
width: 100%;
height: 100vh;
max-height: 480px;
padding: 0 1rem;
border-radius: 5px;
border-width: 1px;
overflow: hidden;
background-color: white;
border-color: var(--border-light);
border-style: solid;
}
================================================
FILE: examples/single-file-sync/styles/globals.css
================================================
@import url('https://rsms.me/inter/inter.css');
:root {
--background: #eeede9;
--border-light: #e3e3e3;
--text-dark: #1c1917;
--text-light: #78716c;
--text-extralight: #a8a29e;
--text-warning: #f97316;
--font-size-base: 16px;
--font-size-sm: 14px;
--button-dark: #0f172a;
--orange-light: #ffedd5;
--green-light: #dcfce7;
--sky-light: #e0f2fe;
--violet-light: #ede9fe;
--rose-light: #ffe4e6;
--amber-light: #fef3c7;
--lime-light: #ecfccb;
--fuchsia-light: #fae8ff;
--orange: #fb923c;
--green: #4ade80;
--sky: #38bdf8;
--violet: #a78bfa;
--rose: #fb7185;
--amber: #fbbf24;
--lime: #a3e635;
--fuchsia: #e879f9;
}
html,
body {
padding: 0;
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
background-color: var(--background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: inherit;
text-decoration: none;
}
* {
box-sizing: border-box;
}
================================================
FILE: examples/single-file-sync/styles/yjs.css
================================================
/* Selection */
.ySelection-0 {
background-color: var(--orange-light);
}
.ySelection-1 {
background-color: var(--green-light);
}
.ySelection-2 {
background-color: var(--sky-light);
}
.ySelection-3 {
background-color: var(--violet-light);
}
.ySelection-4 {
background-color: var(--rose-light);
}
.ySelection-5 {
background-color: var(--amber-light);
}
.ySelection-6 {
background-color: var(--lime-light);
}
.ySelection-7 {
background-color: var(--fuchsia-light);
}
/* Background */
.yBackground-0 {
background-color: var(--orange);
}
.yBackground-1 {
background-color: var(--green);
}
.yBackground-2 {
background-color: var(--sky);
}
.yBackground-3 {
background-color: var(--violet);
}
.yBackground-4 {
background-color: var(--rose);
}
.yBackground-5 {
background-color: var(--amber);
}
.yBackground-6 {
background-color: var(--lime);
}
.yBackground-7 {
background-color: var(--fuchsia);
}
/* Border */
.yBorder-0 {
border-color: var(--orange);
}
.yBorder-1 {
border-color: var(--green);
}
.yBorder-2 {
border-color: var(--sky);
}
.yBorder-3 {
border-color: var(--violet);
}
.yBorder-4 {
border-color: var(--rose);
}
.yBorder-5 {
border-color: var(--amber);
}
.yBorder-6 {
border-color: var(--lime);
}
.yBorder-7 {
border-color: var(--fuchsia);
}
/* Selection head */
.ySelectionHead {
position: absolute;
box-sizing: border-box;
height: 100%;
border-left-width: 2px;
border-top-width: 1px;
border-bottom-width: 1px;
}
================================================
FILE: examples/single-file-sync/tsconfig.json
================================================
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"baseUrl": "./",
"target": "esnext",
"lib": [
"dom",
"dom.iterable",
"esnext",
"webworker"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"downlevelIteration": true,
"incremental": true
},
"exclude": [
"node_modules"
],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
]
}
================================================
FILE: examples/single-file-sync/types.d.ts
================================================
declare module 'y-monaco'
================================================
FILE: package.json
================================================
{
"name": "@yfs/react",
"version": "0.1.0",
"description": "Synchronize text files between the browser and the file system",
"license": "MIT",
"repository": "git@github.com:motifland/yfs.git",
"author": {
"name": "Michael Fester",
"email": "michael.fester@gmail.com",
"url": "https://motif.land"
},
"sideEffects": false,
"type": "module",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "rimraf ./dist && tsc",
"prepublish": "npm run tsc",
"clean": "rimraf ./dist"
},
"dependencies": {
"diff": "^5.0.0",
"idb-keyval": "^6.1.0",
"react": "18.1.0",
"yjs": "^13.5.35"
},
"devDependencies": {
"@types/diff": "^5.0.2",
"@types/node": "^17.0.30",
"@types/react": "^18.0.8",
"typescript": "^4.6.4"
}
}
================================================
FILE: src/cache.ts
================================================
import { set as idbSet, get as idbGet } from 'idb-keyval'
import { STORE_KEY_CACHED_FS_FILE } from './constants'
type LastWriteCacheData = {
name: string
content: string
lastModified: number
}
const getLastWriteCacheKey = (name: string) => {
return `${STORE_KEY_CACHED_FS_FILE}-${name}`
}
export const setLastWriteCacheData = async (
name: string,
content: string,
lastModified: number
) => {
await idbSet(
getLastWriteCacheKey(name),
JSON.stringify({
name,
content,
lastModified
})
)
}
export const getLastWriteCacheData = async (
name: string
): Promise => {
const jsonFile = await idbGet(getLastWriteCacheKey(name))
if (jsonFile) {
return JSON.parse(jsonFile)
}
return undefined
}
================================================
FILE: src/constants.ts
================================================
export const STORE_KEY_DIRECTORY_HANDLE = 'STORE_KEY_DIRECTORY_HANDLE'
export const STORE_KEY_CACHED_FS_FILE = 'STORE_KEY_CACHED_FS_FILE'
================================================
FILE: src/helpers.ts
================================================
export type HandleWithPath = {
handle: FileSystemHandle
path: string[]
type: 'file' | 'directory'
}
const readWriteOptions = { mode: 'readwrite' }
export const isReadWritePermissionGranted = async (
handle: FileSystemFileHandle | FileSystemDirectoryHandle
) => {
return (await (handle as any).queryPermission(readWriteOptions)) === 'granted'
}
export const askReadWritePermissionsIfNeeded = async (
handle: FileSystemFileHandle | FileSystemDirectoryHandle
) => {
if (await isReadWritePermissionGranted(handle)) {
return true
}
const permission = await (handle as any).requestPermission(readWriteOptions)
return permission === 'granted'
}
const createEmptyFileInFolder = async (
parentDirectoryHandle: FileSystemDirectoryHandle,
name: string
): Promise => {
return await parentDirectoryHandle.getFileHandle(name, { create: true })
}
export const createFolderInFolder = async (
parentDirectoryHandle: FileSystemDirectoryHandle,
name: string
): Promise => {
return await parentDirectoryHandle.getDirectoryHandle(name, { create: true })
}
const writeContentToFile = async (
fileHandle: FileSystemFileHandle,
content: string
) => {
const writable = await (fileHandle as any).createWritable()
await writable.write(content)
await writable.close()
}
export const writeContentToFileIfChanged = async (
fsFile: globalThis.File,
fileHandle: FileSystemFileHandle,
content: string
) => {
const fsFileContent = await fsFile.text()
if (fsFileContent === content) {
return
}
await writeContentToFile(fileHandle, content)
}
export const renameFile = async (
fsFile: globalThis.File,
parentDirectoryHandle: FileSystemDirectoryHandle,
name: string
) => {
// Move and rename is not currently supported by the FileSystem
// Access API so we need to do this they manual way by creating
// a new file and deleting the old one.
const content = await fsFile.text()
await createFile(parentDirectoryHandle, name, content)
await deleteFile(parentDirectoryHandle, fsFile.name)
}
export const moveFile = async (
fsFile: globalThis.File,
sourceDirectoryHandle: FileSystemDirectoryHandle,
destinationDirectoryHandle: FileSystemDirectoryHandle
) => {
// Same comment as renameFile
const content = await fsFile.text()
await createFile(destinationDirectoryHandle, fsFile.name, content)
await deleteFile(sourceDirectoryHandle, fsFile.name)
}
export const moveFolderContent = async (
sourceFolderHandle: FileSystemDirectoryHandle,
destinationFolderHandle: FileSystemDirectoryHandle
) => {
for await (const handle of (sourceFolderHandle as any).values()) {
if (handle.kind === 'file') {
const fsFile = await (handle as FileSystemFileHandle).getFile()
await moveFile(fsFile, sourceFolderHandle, destinationFolderHandle)
} else if (handle.kind === 'directory') {
await moveFolder(handle, sourceFolderHandle, destinationFolderHandle)
}
}
}
export const moveFolder = async (
folderHandle: FileSystemDirectoryHandle,
parentDirectoryHandle: FileSystemDirectoryHandle,
destinationFolderHandle: FileSystemDirectoryHandle
) => {
const newFolderHandle = await createFolderInFolder(
destinationFolderHandle,
folderHandle.name
)
await moveFolderContent(folderHandle, newFolderHandle)
await deleteFolder(folderHandle.name, parentDirectoryHandle)
}
export const renameFolder = async (
folderHandle: FileSystemDirectoryHandle,
parentDirectoryHandle: FileSystemDirectoryHandle,
newName: string
) => {
const newFolderHandle = await createFolderInFolder(
parentDirectoryHandle,
newName
)
try {
await moveFolderContent(folderHandle, newFolderHandle)
await deleteFolder(folderHandle.name, parentDirectoryHandle)
} catch {
// Do nothing
}
}
export const createFile = async (
parentDirectoryHandle: FileSystemDirectoryHandle,
name: string,
content: string
): Promise => {
const newFileHandle = await createEmptyFileInFolder(
parentDirectoryHandle,
name
)
await writeContentToFile(newFileHandle, content)
return newFileHandle
}
export const deleteFile = async (
parentDirectoryHandle: FileSystemDirectoryHandle,
name: string
) => {
await parentDirectoryHandle.removeEntry(name)
}
export const deleteFolder = async (
name: string,
parentDirectoryHandle: FileSystemDirectoryHandle
) => {
await parentDirectoryHandle.removeEntry(name, {
recursive: true
})
}
export const getFSFileHandle = async (
name: string,
directoryHandle: FileSystemDirectoryHandle
): Promise => {
for await (const handle of (directoryHandle as any).values()) {
const relativePath = (await directoryHandle.resolve(handle)) || []
if (relativePath?.length === 1 && relativePath[0] === name) {
return handle
}
}
return undefined
}
export const isHandlesEqual = async (
handle: FileSystemHandle | undefined,
otherHandle: FileSystemHandle | undefined
) => {
if (!handle && !otherHandle) {
return true
}
if (handle && otherHandle) {
return await (handle as any)?.isSameEntry(otherHandle)
}
return false
}
export const isIgnoredPath = (path: string[]): boolean => {
// Return true if the file at the given path should be ignored for
// syncing. This is the case currently if the path contains a component
// that starts with a period, e.g. ".git" or ".DS_Store".
return !!path.find(p => p.startsWith('.') || p.endsWith('.crswap'))
}
export const isTextMimeType = (file: globalThis.File) => {
// Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types
return file.type.startsWith('text/') || !file.type
}
================================================
FILE: src/index.ts
================================================
import { useCallback, useEffect, useMemo, useState } from 'react'
import { set as idbSet, get as idbGet, del as idbDel } from 'idb-keyval'
import * as Y from 'yjs'
import { STORE_KEY_DIRECTORY_HANDLE } from './constants'
import {
askReadWritePermissionsIfNeeded,
createFile,
getFSFileHandle,
writeContentToFileIfChanged
} from './helpers'
import { getLastWriteCacheData, setLastWriteCacheData } from './cache'
import { getDeltaOperations } from './yjs'
const useFileSync = () => {
const [isSupported, setSupported] = useState(false)
const [isWritePermissionGranted, setWritePermissionGranted] = useState(false)
const [directoryHandle, setDirectoryHandle] = useState<
FileSystemDirectoryHandle | undefined
>(undefined)
useEffect(() => {
setSupported(typeof (window as any).showDirectoryPicker === 'function')
}, [])
useEffect(() => {
const loadHandle = async () => {
const handle = await idbGet(STORE_KEY_DIRECTORY_HANDLE)
if (handle) {
setDirectoryHandle(handle)
}
}
loadHandle()
}, [])
useEffect(() => {
if (directoryHandle) {
idbSet(STORE_KEY_DIRECTORY_HANDLE, directoryHandle)
}
}, [directoryHandle])
const grantWritePermission = useCallback(async () => {
if (!isSupported || !directoryHandle) {
return
}
try {
const granted = await askReadWritePermissionsIfNeeded(directoryHandle)
setWritePermissionGranted(granted)
} catch {}
}, [isSupported, directoryHandle])
const setRootDirectory = useCallback(
async (withWritePermission: boolean) => {
if (!isSupported) {
return
}
try {
const handle = await (window as any).showDirectoryPicker()
if (handle) {
setDirectoryHandle(handle)
if (withWritePermission) {
const granted = await askReadWritePermissionsIfNeeded(handle)
setWritePermissionGranted(granted)
}
}
} catch {}
},
[isSupported, grantWritePermission]
)
const unsetRootDirectory = useCallback(async () => {
setDirectoryHandle(undefined)
idbDel(STORE_KEY_DIRECTORY_HANDLE)
}, [])
const syncDoc = useCallback(
async (name: string, doc: Y.Doc) => {
if (!directoryHandle) {
return
}
const updateFileContent = async (
file: globalThis.File,
fileHandle: FileSystemFileHandle,
newContent: string
) => {
// When we write to the file system, we also save a version
// in cache in order to be able to watch for subsequent changes
// to the file.
await writeContentToFileIfChanged(file, fileHandle, newContent)
await setLastWriteCacheData(name, newContent, file.lastModified)
}
let fileHandle = await getFSFileHandle(name, directoryHandle)
const docContent = doc.getText().toString()
if (!fileHandle) {
// File is not present in the file system, so create it.
const newFileHandle = await createFile(directoryHandle, name, '')
const newFile = await newFileHandle.getFile()
await updateFileContent(newFile, newFileHandle, docContent)
return
}
const file = await fileHandle.getFile()
// File exists, so compare it with the last-write-cache.
const lastWriteCacheData = await getLastWriteCacheData(name)
if (!lastWriteCacheData) {
// Cached version does not exist. This should never happen. Indeed,
// even if the user clears the app data, the directory handle will
// be cleared as well, so the user will be asked to select a directory
// again, in which case a hard overwrite will happen, and the
// last-write-cache will be populated. So in case `lastWriteCacheData`
// does not exist, we can consider this situation as similar to the
// initial file dump situation and simply overwrite the FS file.
await updateFileContent(file, fileHandle, docContent)
return
}
// Cached version exists. This allows us to see the changes in the
// local file, and compute the diff which in turn gives us as
// state update vector for our CRDT. We can then apply it
// to the app file for a seamless merging of the two versions.
if (file.lastModified === lastWriteCacheData.lastModified) {
// File has not changed in the file system. Since the FS file cache
// is only set when a project file is synced, this means that the
// only option is that the app file has changed, in which
// case it should be written to the FS file.
await updateFileContent(file, fileHandle, docContent)
return
}
// File has changed in the file system.
const fileContent = await file.text()
const lastWriteFileContent = lastWriteCacheData.content
const deltas = getDeltaOperations(lastWriteFileContent, fileContent)
if (deltas.length === 0) {
// Same comment as above: no difference between FS file and
// and last-write-cache, so just write the app file to FS.
await updateFileContent(file, fileHandle, docContent)
return
}
// A change has happened in the file, since it differs
// from the cached version. So we merge it with the app doc.
doc.getText().applyDelta(deltas)
const mergedContent = doc.getText().toString()
await updateFileContent(file, fileHandle, mergedContent)
},
[directoryHandle]
)
const directoryName = useMemo(() => {
return directoryHandle?.name
}, [directoryHandle])
return {
isSupported,
setRootDirectory,
unsetRootDirectory,
grantWritePermission,
isWritePermissionGranted,
directoryName,
syncDoc
}
}
export default useFileSync
================================================
FILE: src/yjs.ts
================================================
import * as Diff from 'diff'
type YDelta = { retain: number } | { delete: number } | { insert: string }
// Compute the set of Yjs delta operations (that is, `insert` and
// `delete`) required to go from initialText to finalText.
// Based on https://github.com/kpdecker/jsdiff.
export const getDeltaOperations = (
initialText: string,
finalText: string
): YDelta[] => {
if (initialText === finalText) {
return []
}
const edits = Diff.diffChars(initialText, finalText)
let prevOffset = 0
let deltas: YDelta[] = []
for (const edit of edits) {
if (edit.removed && edit.value) {
deltas = [
...deltas,
...[
...(prevOffset > 0 ? [{ retain: prevOffset }] : []),
{ delete: edit.value.length }
]
]
prevOffset = 0
} else if (edit.added && edit.value) {
deltas = [...deltas, ...[{ retain: prevOffset }, { insert: edit.value }]]
prevOffset = edit.value.length
} else {
prevOffset = edit.value.length
}
}
return deltas
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"declaration": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"module": "ES2020",
"moduleResolution": "node",
"outDir": "./dist",
"strict": true,
"target": "ES2020"
},
"include": ["./src"]
}