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
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
@@ -24,6 +24,7 @@ export const EditContainer = () => {
abortFindNote,
findOrCreateNote,
initNote,
resetLocalDocState,
note,
} = NoteState.useContainer()
const { query } = useRouter()
@@ -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)
@@ -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 })
@@ -68,11 +74,11 @@ export const EditContainer = () => {

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

if (!isNew && id !== 'new') {
// todo: store in localStorage
mutateSettings({
last_visit: `/${id}`,
})
@@ -84,6 +90,7 @@ export const EditContainer = () => {
settings.daily_root_id,
genNewId,
pid,
resetLocalDocState,
fetchNote,
toast,
initNote,
@@ -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)
6 changes: 1 addition & 5 deletions components/editor/editor.tsx
Original file line number Diff line number Diff line change
@@ -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'
@@ -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()
@@ -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)}
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
@@ -58,6 +58,8 @@ const NoteNav = () => {
[note, menu]
)

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

return (
<nav
className={classNames(
@@ -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>
6 changes: 3 additions & 3 deletions components/portal/preview-modal.tsx
Original file line number Diff line number Diff line change
@@ -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(() => {
4 changes: 2 additions & 2 deletions libs/server/note.ts
Original file line number Diff line number Diff line change
@@ -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()
@@ -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,
})
2 changes: 1 addition & 1 deletion libs/server/store/providers/base.ts
Original file line number Diff line number Diff line change
@@ -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>
2 changes: 1 addition & 1 deletion libs/server/store/providers/s3.ts
Original file line number Diff line number Diff line change
@@ -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
) {
16 changes: 16 additions & 0 deletions libs/shared/note.ts
Original file line number Diff line number Diff line change
@@ -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[]
}

/**
@@ -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
@@ -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)
}
}

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
@@ -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)
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'
@@ -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]
@@ -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',
Loading