Skip to content

feat: offline editing #101

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions components/container/edit-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const EditContainer = () => {
abortFindNote,
findOrCreateNote,
initNote,
resetLocalDocState,
note,
} = NoteState.useContainer()
const { query } = useRouter()
Expand All @@ -40,13 +41,15 @@ export const EditContainer = () => {
findOrCreateNote(id, {
id,
title: id,
content: '\n',
pid: settings.daily_root_id,
})
// you can create a note via `/new`
} else if (id === 'new') {
const url = `/${genNewId()}?new` + (pid ? `&pid=${pid}` : '')

router.replace(url, undefined, { shallow: true })
resetLocalDocState()
// fetch note by id
} else if (id && !isNew) {
try {
const result = await fetchNote(id)
Expand All @@ -55,11 +58,14 @@ export const EditContainer = () => {
return
}
} catch (msg) {
if (msg.name !== 'AbortError') {
toast(msg.message, 'error')
router.push('/', undefined, { shallow: true })
if (msg instanceof Error) {
if (msg.name !== 'AbortError') {
toast(msg.message, 'error')
router.push('/', undefined, { shallow: true })
}
}
}
// default
} else {
if (await noteCache.getItem(id)) {
router.push(`/${id}`, undefined, { shallow: true })
Expand All @@ -68,11 +74,11 @@ export const EditContainer = () => {

initNote({
id,
content: '\n',
})
}

if (!isNew && id !== 'new') {
// todo: store in localStorage
mutateSettings({
last_visit: `/${id}`,
})
Expand All @@ -84,6 +90,7 @@ export const EditContainer = () => {
settings.daily_root_id,
genNewId,
pid,
resetLocalDocState,
fetchNote,
toast,
initNote,
Expand All @@ -94,7 +101,8 @@ export const EditContainer = () => {
useEffect(() => {
abortFindNote()
loadNoteById(id)
}, [loadNoteById, abortFindNote, id])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])

useEffect(() => {
updateTitle(note?.title)
Expand Down
6 changes: 1 addition & 5 deletions components/editor/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { FC, useEffect, useState } from 'react'
import { use100vh } from 'react-div-100vh'
import MarkdownEditor, { Props } from 'rich-markdown-editor'
import { useEditorTheme } from './theme'
import useMounted from 'libs/web/hooks/use-mounted'
import Tooltip from './tooltip'
import extensions from './extensions'
import EditorState from 'libs/web/state/editor'
Expand All @@ -21,13 +20,11 @@ const Editor: FC<EditorProps> = ({ readOnly, isPreview }) => {
onClickLink,
onUploadImage,
onHoverLink,
onEditorChange,
backlinks,
editorEl,
note,
} = EditorState.useContainer()
const height = use100vh()
const mounted = useMounted()
const editorTheme = useEditorTheme()
const [hasMinHeight, setHasMinHeight] = useState(true)
const toast = useToast()
Expand All @@ -44,9 +41,8 @@ const Editor: FC<EditorProps> = ({ readOnly, isPreview }) => {
<MarkdownEditor
readOnly={readOnly}
id={note?.id}
key={note?.id}
ref={editorEl}
value={mounted ? note?.content : ''}
onChange={onEditorChange}
placeholder={dictionary.editorPlaceholder}
theme={editorTheme}
uploadImage={(file) => onUploadImage(file, note?.id)}
Expand Down
3 changes: 2 additions & 1 deletion components/editor/extensions/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Extension } from 'rich-markdown-editor'
import Bracket from './bracket'
import YSync from './y-sync'

const extensions: Extension[] = [new Bracket()]
const extensions: Extension[] = [new Bracket(), new YSync()]

export default extensions
26 changes: 26 additions & 0 deletions components/editor/extensions/y-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Extension } from 'rich-markdown-editor'
import { ySyncPlugin } from 'y-prosemirror'
import * as Y from 'yjs'

export const YJS_DOC_KEY = 'prosemirror'
export default class YSync extends Extension {
get name() {
return 'y-sync'
}

yDoc?: Y.Doc

constructor(options?: Record<string, any>) {
super(options)
}

get plugins() {
if (this.yDoc) {
this.yDoc.destroy()
}
this.yDoc = new Y.Doc()
const type = this.yDoc.get(YJS_DOC_KEY, Y.XmlFragment)

return [ySyncPlugin(type)]
}
}
10 changes: 6 additions & 4 deletions components/note-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ const NoteNav = () => {
[note, menu]
)

const getTitle = (title?: string) => title ?? t('Untitled')

return (
<nav
className={classNames(
Expand All @@ -82,22 +84,22 @@ const NoteNav = () => {
{getPaths(note)
.reverse()
.map((path) => (
<Tooltip key={path.id} title={path.title}>
<Tooltip key={path.id} title={getTitle(path.title)}>
<div>
<Link href={`/${path.id}`} shallow>
<a className="title block hover:bg-gray-200 px-1 py-0.5 rounded text-sm truncate">
{path.title}
{getTitle(path.title)}
</a>
</Link>
</div>
</Tooltip>
))}
<Tooltip title={note.title}>
<Tooltip title={getTitle(note.title)}>
<span
className="title block text-gray-600 text-sm truncate select-none"
aria-current="page"
>
{note.title}
{getTitle(note.title)}
</span>
</Tooltip>
</Breadcrumbs>
Expand Down
6 changes: 3 additions & 3 deletions components/portal/preview-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,14 @@ const PreviewModal: FC = () => {
preview: { anchor, open, close, visible, data, setAnchor },
} = PortalState.useContainer()
const router = useRouter()
const { fetch: fetchNote } = useNoteAPI()
const { fetch: fetchNoteAPI } = useNoteAPI()
const [note, setNote] = useState<NoteCacheItem>()

const findNote = useCallback(
async (id: string) => {
setNote(await fetchNote(id))
setNote(await fetchNoteAPI(id))
},
[fetchNote]
[fetchNoteAPI]
)

const gotoLink = useCallback(() => {
Expand Down
4 changes: 2 additions & 2 deletions libs/server/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { getPathNoteById } from 'libs/server/note-path'
import { ServerState } from './connect'

export const createNote = async (note: NoteModel, state: ServerState) => {
const { content = '\n', ...meta } = note
const { updates = [], ...meta } = note

if (!note.id) {
note.id = genId()
Expand All @@ -21,7 +21,7 @@ export const createNote = async (note: NoteModel, state: ServerState) => {
}
const metaData = jsonToMeta(metaWithModel)

await state.store.putObject(getPathNoteById(note.id), content, {
await state.store.putObject(getPathNoteById(note.id), updates, {
contentType: 'text/markdown',
meta: metaData,
})
Expand Down
2 changes: 1 addition & 1 deletion libs/server/store/providers/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export abstract class StoreProvider {
*/
abstract putObject(
path: string,
raw: string | Buffer,
raw: string | Buffer | Array<string>,
headers?: ObjectOptions,
isCompressed?: boolean
): Promise<void>
Expand Down
2 changes: 1 addition & 1 deletion libs/server/store/providers/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export class StoreS3 extends StoreProvider {

async putObject(
path: string,
raw: string | Buffer,
raw: string | Buffer | Array<string>,
options?: ObjectOptions,
isCompressed?: boolean
) {
Expand Down
16 changes: 16 additions & 0 deletions libs/shared/note.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ export interface NoteModel {
id: string
title: string
pid?: string
/**
* @deprecated
*/
content?: string
pic?: string
date?: string
deleted: NOTE_DELETED
shared: NOTE_SHARED
pinned: NOTE_PINNED
editorsize: EDITOR_SIZE | null
updates?: string[]
}

/**
Expand All @@ -21,3 +25,15 @@ export const isNoteLink = (str: string) => {
}

export const NOTE_ID_REGEXP = '[A-Za-z0-9_-]+'

export const extractNoteLink = (str: string) => {
const regexp = new RegExp(`href="/(${NOTE_ID_REGEXP})"`, 'g')
let match
const links = []

while ((match = regexp.exec(str)) !== null) {
links.push(match[1])
}

return links
}
7 changes: 3 additions & 4 deletions libs/shared/str.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,13 @@ export function toStr(
return deCompressed ? strDecompress(str) : str
}

export function tryJSON<T>(str?: string | null): T | null {
if (isNil(str)) return null
export function tryJSON<T>(str?: string | null): T | undefined {
if (isNil(str)) return undefined

try {
return JSON.parse(str)
} catch (e) {
console.error('parse error', str)
return null
console.log('parse error', str)
}
}

Expand Down
46 changes: 46 additions & 0 deletions libs/shared/y-doc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { fromUint8Array, toUint8Array } from 'js-base64'
import * as Y from 'yjs'

export const mergeUpdates = (updates: (string | Uint8Array)[], sv?: string) => {
const doc = new Y.Doc()

doc.transact(() => {
updates.forEach((val) =>
Y.applyUpdate(doc, typeof val === 'string' ? toUint8Array(val) : val)
)
})

const update = sv
? Y.diffUpdate(Y.encodeStateAsUpdate(doc), toUint8Array(sv))
: Y.encodeStateAsUpdate(doc)

doc.destroy()

return fromUint8Array(update)
}

export const mergeUpdatesToLimit = (updates: string[], limit: number) => {
const doc = new Y.Doc()
const curUpdates = [...updates]

doc.transact(() => {
while (curUpdates.length >= limit) {
const update = curUpdates.shift()

if (update) {
Y.applyUpdate(
doc,
typeof update === 'string' ? toUint8Array(update) : update
)
}
}
})

if (curUpdates.length < updates.length) {
curUpdates.unshift(fromUint8Array(Y.encodeStateAsUpdate(doc)))
}

doc.destroy()

return curUpdates
}
2 changes: 1 addition & 1 deletion libs/web/api/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export default function useFetcher() {
return response.json()
} catch (e) {
if (!controller?.signal.aborted) {
setError(e)
setError(e as any)
}
} finally {
setLoading(false)
Expand Down
11 changes: 8 additions & 3 deletions libs/web/api/note.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NoteModel } from 'libs/shared/note'
import { encode } from 'qss'
import { useCallback } from 'react'
import noteCache from '../cache/note'
import useFetcher from './fetcher'
Expand All @@ -7,10 +8,14 @@ export default function useNoteAPI() {
const { loading, request, abort, error } = useFetcher()

const find = useCallback(
async (id: string) => {
async (id: string, params?: { sv: string }) => {
let qs = ''
if (params) {
qs = '?' + encode(params)
}
return request<null, NoteModel>({
method: 'GET',
url: `/api/notes/${id}`,
url: `/api/notes/${id}` + qs,
})
},
[request]
Expand All @@ -31,7 +36,7 @@ export default function useNoteAPI() {

const mutate = useCallback(
async (id: string, body: Partial<NoteModel>) => {
const data = body.content
const data = body.updates
? await request<Partial<NoteModel>, NoteModel>(
{
method: 'POST',
Expand Down
Loading