Skip to content
Merged
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
8 changes: 7 additions & 1 deletion apps/desktop/src/lib/localStoragePersist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ export function localStoragePersist<T extends StateObject | StatePrimitive>(
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;
});
},
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/store/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ export const STORAGE_KEY = "hubble-desktop-app";

function readStorage<T>(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;
Expand Down
1 change: 0 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
27 changes: 27 additions & 0 deletions packages/editor/src/ImageMarkdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
24 changes: 24 additions & 0 deletions packages/editor/src/Link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
});
});
35 changes: 22 additions & 13 deletions packages/editor/src/Link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pick<LinkAttrs, "kind" | "target">>,
Expand Down Expand Up @@ -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;

Expand All @@ -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 };
}
2 changes: 1 addition & 1 deletion packages/editor/src/List.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
44 changes: 41 additions & 3 deletions packages/editor/src/markdownToProsemirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 [
Expand Down
6 changes: 6 additions & 0 deletions packages/sync/src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/editor/EditorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/editor/MarkdownSourceEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
21 changes: 21 additions & 0 deletions packages/ui/src/editor/taskListSplit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down