diff --git a/apps/desktop/src/lib/localStoragePersist.ts b/apps/desktop/src/lib/localStoragePersist.ts index a67e3dfe..cafa1943 100644 --- a/apps/desktop/src/lib/localStoragePersist.ts +++ b/apps/desktop/src/lib/localStoragePersist.ts @@ -14,7 +14,13 @@ export function localStoragePersist( const nextState = typeof setter === "function" ? setter(current) : setter; const toStore = serialize ? serialize(nextState) : nextState; - localStorage.setItem(key, JSON.stringify(toStore)); + try { + localStorage.setItem(key, JSON.stringify(toStore)); + } catch (error) { + // Persistence is best-effort; a storage quota/security error + // must not prevent the in-memory state update. + console.warn(`Failed to persist "${key}" to localStorage`, error); + } return nextState; }); }, diff --git a/apps/desktop/src/store/persistence.ts b/apps/desktop/src/store/persistence.ts index ab2149c4..069d941b 100644 --- a/apps/desktop/src/store/persistence.ts +++ b/apps/desktop/src/store/persistence.ts @@ -54,10 +54,10 @@ export const STORAGE_KEY = "hubble-desktop-app"; function readStorage(key: string): T | null { if (typeof localStorage === "undefined") return null; - const raw = localStorage.getItem(key); - if (!raw) return null; try { + const raw = localStorage.getItem(key); + if (!raw) return null; return JSON.parse(raw) as T; } catch { return null; diff --git a/packages/cli/package.json b/packages/cli/package.json index 51fa5305..7bd64e40 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,7 +20,6 @@ ], "scripts": { "build": "tsc -p tsconfig.json", - "postbuild": "chmod +x dist/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" }, diff --git a/packages/editor/src/ImageMarkdown.test.ts b/packages/editor/src/ImageMarkdown.test.ts index af7c064f..44a4dcb0 100644 --- a/packages/editor/src/ImageMarkdown.test.ts +++ b/packages/editor/src/ImageMarkdown.test.ts @@ -34,4 +34,31 @@ describe("image markdown conversion", () => { const doc = markdownToTiptapDoc("before\n\n![]()\n\nafter"); expect(doc.content?.some((node) => node.type === "image")).toBe(false); }); + + it("keeps both the image and text that follows it", () => { + const doc = markdownToTiptapDoc("![diagram](example.png) trailing"); + + expect(doc.content).toMatchObject([ + { type: "image", attrs: { src: "example.png", alt: "diagram" } }, + { type: "paragraph", content: [{ type: "text", text: "trailing" }] }, + ]); + }); + + it("keeps images mixed with surrounding text", () => { + const doc = markdownToTiptapDoc("before ![diagram](example.png) after"); + + expect(doc.content).toMatchObject([ + { type: "paragraph", content: [{ type: "text", text: "before" }] }, + { type: "image", attrs: { src: "example.png", alt: "diagram" } }, + { type: "paragraph", content: [{ type: "text", text: "after" }] }, + ]); + }); + + it("round-trips an image with trailing text without losing the image", () => { + const doc = markdownToTiptapDoc("![diagram](example.png) trailing"); + const markdown = tiptapDocToMarkdown(doc); + + expect(markdown).toContain("![diagram](example.png)"); + expect(markdown).toContain("trailing"); + }); }); diff --git a/packages/editor/src/Link.test.ts b/packages/editor/src/Link.test.ts index 2dedc647..10f4fc25 100644 --- a/packages/editor/src/Link.test.ts +++ b/packages/editor/src/Link.test.ts @@ -46,4 +46,28 @@ describe("getActiveLinkRange", () => { target: null, }); }); + + it("does not merge adjacent links with different attributes", () => { + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, [ + schema.text("a", [ + schema.marks.link.create({ href: "https://a.example" }), + ]), + schema.text("b", [ + schema.marks.link.create({ href: "https://b.example" }), + ]), + ]), + ]); + const state = EditorState.create({ + schema, + doc, + selection: TextSelection.create(doc, 1), + }); + + expect(getActiveLinkRange(state)).toMatchObject({ + from: 1, + to: 2, + href: "https://a.example", + }); + }); }); diff --git a/packages/editor/src/Link.ts b/packages/editor/src/Link.ts index 52e3ca69..d81c5346 100644 --- a/packages/editor/src/Link.ts +++ b/packages/editor/src/Link.ts @@ -78,6 +78,14 @@ export type LinkAttrs = { target: string | null; }; +function sameLinkAttrs(left: LinkAttrs, right: LinkAttrs): boolean { + return ( + left.href === right.href && + left.kind === right.kind && + left.target === right.target + ); +} + export function createLinkMark( href = "", attrs?: Partial>, @@ -136,6 +144,10 @@ export function getActiveLinkRange(state: EditorState): { return { from: selection.from, to: selection.from, ...attrs }; } + const mark = markType.isInSet(parent.child(index).marks); + const attrs = mark ? getLinkAttrs(mark.attrs) : null; + if (attrs === null) return null; + let startIndex = index; let endIndex = index; @@ -145,26 +157,23 @@ export function getActiveLinkRange(state: EditorState): { } let to = from + parent.child(index).nodeSize; - while ( - startIndex > 0 && - !!markType.isInSet(parent.child(startIndex - 1).marks) - ) { + while (startIndex > 0) { + const previousMark = markType.isInSet(parent.child(startIndex - 1).marks); + const previousAttrs = previousMark + ? getLinkAttrs(previousMark.attrs) + : null; + if (!previousAttrs || !sameLinkAttrs(previousAttrs, attrs)) break; startIndex -= 1; from -= parent.child(startIndex).nodeSize; } - while ( - endIndex + 1 < parent.childCount && - !!markType.isInSet(parent.child(endIndex + 1).marks) - ) { + while (endIndex + 1 < parent.childCount) { + const nextMark = markType.isInSet(parent.child(endIndex + 1).marks); + const nextAttrs = nextMark ? getLinkAttrs(nextMark.attrs) : null; + if (!nextAttrs || !sameLinkAttrs(nextAttrs, attrs)) break; endIndex += 1; to += parent.child(endIndex).nodeSize; } - const mark = - markType.isInSet(parent.child(index).marks) ?? - markType.create({ href: "" }); - const attrs = getLinkAttrs(mark.attrs); - if (attrs === null) return null; return { from, to, ...attrs }; } diff --git a/packages/editor/src/List.ts b/packages/editor/src/List.ts index 689a0853..36aaffc5 100644 --- a/packages/editor/src/List.ts +++ b/packages/editor/src/List.ts @@ -419,7 +419,7 @@ function setListItemType( (node, pos) => { const $pos = tr.doc.resolve(pos); if ($pos.depth <= $nearestListPos.depth) return true; - hasChangedAny = itemType(node) !== type; + hasChangedAny ||= itemType(node) !== type; tr.setNodeMarkup(pos, undefined, { checked: type === "task" ? false : null, }); diff --git a/packages/editor/src/markdownToProsemirror.ts b/packages/editor/src/markdownToProsemirror.ts index 7e40fee0..4b6fb158 100644 --- a/packages/editor/src/markdownToProsemirror.ts +++ b/packages/editor/src/markdownToProsemirror.ts @@ -35,9 +35,8 @@ function normalizeBlockContent(children: Content[]): Content[] { function blockToPM(node: Content): JSONContent[] { switch (node.type) { case "paragraph": { - const [maybeImage] = node.children; - if (maybeImage?.type === "image") { - return imageToPM(maybeImage); + if (node.children.some((child) => child.type === "image")) { + return splitParagraphAroundImages(node.children); } const paragraphHtml = node.children.every( (child) => child.type === "html", @@ -241,6 +240,45 @@ function listItemToPM(li: ListItem, allowChecked: boolean): JSONContent[] { ]; } +// Images are block nodes in the editor schema, so a paragraph mixing images +// with other inline content becomes image blocks with paragraphs between them. +function splitParagraphAroundImages(children: Content[]): JSONContent[] { + const blocks: JSONContent[] = []; + let run: Content[] = []; + const flushRun = () => { + const content = inlineToPM(trimInlineRun(run)); + run = []; + if (content.length > 0) blocks.push({ type: "paragraph", content }); + }; + for (const child of children) { + if (child.type === "image") { + flushRun(); + blocks.push(...imageToPM(child)); + } else { + run.push(child); + } + } + flushRun(); + return blocks; +} + +function trimInlineRun(run: Content[]): Content[] { + const trimmed = [...run]; + const first = trimmed[0]; + if (first?.type === "text") { + const value = first.value.trimStart(); + if (value) trimmed[0] = { ...first, value }; + else trimmed.shift(); + } + const last = trimmed[trimmed.length - 1]; + if (last?.type === "text") { + const value = last.value.trimEnd(); + if (value) trimmed[trimmed.length - 1] = { ...last, value }; + else trimmed.pop(); + } + return trimmed; +} + function imageToPM(imageNode: Image): JSONContent[] { if (!imageNode.url) return []; return [ diff --git a/packages/sync/src/sync.ts b/packages/sync/src/sync.ts index aa8cba80..87b42920 100644 --- a/packages/sync/src/sync.ts +++ b/packages/sync/src/sync.ts @@ -212,6 +212,9 @@ export async function sync( headers: { "Content-Type": "application/octet-stream" }, body: data, }); + if (!res.ok) { + throw new Error(`Asset upload failed: ${res.status}`); + } const { storageId } = (await res.json()) as { storageId: string }; await backend.pushAsset({ workspaceId, @@ -228,6 +231,9 @@ export async function sync( const url = await backend.getAssetDownloadUrl(remote.storageId); if (!url) return; const res = await fetch(url); + if (!res.ok) { + throw new Error(`Asset download failed: ${res.status}`); + } const buf = new Uint8Array(await res.arrayBuffer()); await ensureParentDir(remote.path); await fs.writeBinaryFile(`${workspacePath}/${remote.path}`, buf); diff --git a/packages/ui/src/editor/EditorView.tsx b/packages/ui/src/editor/EditorView.tsx index b5ea64ee..80c96917 100644 --- a/packages/ui/src/editor/EditorView.tsx +++ b/packages/ui/src/editor/EditorView.tsx @@ -142,6 +142,7 @@ export function EditorView({ window.clearTimeout(saveTimerRef.current); } saveTimerRef.current = window.setTimeout(() => { + saveTimerRef.current = null; void onSave(savePath, latestMarkdownRef.current); }, saveDebounceMs); }, [onSave, saveDebounceMs]); diff --git a/packages/ui/src/editor/MarkdownSourceEditor.tsx b/packages/ui/src/editor/MarkdownSourceEditor.tsx index f1ab830a..24ec3e83 100644 --- a/packages/ui/src/editor/MarkdownSourceEditor.tsx +++ b/packages/ui/src/editor/MarkdownSourceEditor.tsx @@ -44,6 +44,7 @@ export function MarkdownSourceEditor({ window.clearTimeout(saveTimerRef.current); } saveTimerRef.current = window.setTimeout(() => { + saveTimerRef.current = null; void onSave(savePath, latestMarkdownRef.current); }, saveDebounceMs); }, [onSave, saveDebounceMs]); diff --git a/packages/ui/src/editor/taskListSplit.test.ts b/packages/ui/src/editor/taskListSplit.test.ts index 57fa9d1d..e11d9e94 100644 --- a/packages/ui/src/editor/taskListSplit.test.ts +++ b/packages/ui/src/editor/taskListSplit.test.ts @@ -25,6 +25,27 @@ describe("task list splitting", () => { taskItem({ checked: false }), ]); }); + + it("converts every item when toggling a mixed list to bullets", () => { + const editor = createEditor({ + type: "doc", + content: [ + { + type: "bulletList", + content: [ + taskItem({ checked: true, text: "done" }), + taskItem({ checked: null, text: "plain" }), + ], + }, + ], + }); + + expect(editor.commands.toggleParentBulletList()).toBe(true); + expect(listItems(editor)).toMatchObject([ + taskItem({ checked: null, text: "done" }), + taskItem({ checked: null, text: "plain" }), + ]); + }); }); function createEditor(content: JSONContent) {