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
25 changes: 12 additions & 13 deletions e2e/markdown-editor-fuzz.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,9 @@ test("fuzzes markdown editor plain text in a real browser", async ({
test("fuzzes markdown editor plain text through the Flashtype UI", async ({
browserName: _browserName,
}, testInfo) => {
const seed =
process.env.FLASHTYPE_MARKDOWN_UI_FUZZ_SEED ??
process.env.FLASHTYPE_MARKDOWN_FUZZ_SEED ??
MARKDOWN_EDITOR_FUZZ_DEFAULT_SEED;
const operationCount = markdownUiFuzzOperationCount();
const rng = seedrandom(seed);
const operationCount = 300;
const seed = testInfo.repeatEachIndex;
const rng = seedrandom(seed.toString());
const state = createSimplifiedState();
const workspaceDir = testInfo.outputPath("workspace-ui-fuzz");
const fuzzFile = path.join(workspaceDir, "fuzz.md");
Expand Down Expand Up @@ -134,11 +131,13 @@ test("fuzzes markdown editor plain text through the Flashtype UI", async ({
try {
await applyOperationToUiPage(page, operation);
applyOperationToSimplifiedState(state, operation);
const delayMs = Math.floor(rng() * (1000 + 1));
await page.waitForTimeout(delayMs);
} catch (error) {
const snapshot = await safeUiSnapshot(page);
throw new Error(
buildUiOperationFailureMessage({
seed,
seed: seed,
index,
operation,
state,
Expand All @@ -154,7 +153,7 @@ test("fuzzes markdown editor plain text through the Flashtype UI", async ({
if (snapshot.plainText !== expected) {
throw new Error(
buildUiPlainTextMismatchMessage({
seed,
seed: seed,
index,
operation,
state,
Expand Down Expand Up @@ -187,7 +186,7 @@ test("fuzzes markdown editor plain text through the Flashtype UI", async ({
function assertSnapshotSelectionMatches(
snapshot: MarkdownFuzzSnapshot,
state: SimplifiedState,
seed: string,
seed: number,
index: number,
operation: FuzzOperation,
): void {
Expand Down Expand Up @@ -375,7 +374,7 @@ function markdownUiFuzzOperationCount(): number {
}

function buildUiOperationFailureMessage(args: {
seed: string;
seed: number;
index: number;
operation: FuzzOperation;
state: SimplifiedState;
Expand All @@ -399,7 +398,7 @@ function buildUiOperationFailureMessage(args: {
}

function buildUiPlainTextMismatchMessage(args: {
seed: string;
seed: number;
index: number;
operation: FuzzOperation;
state: SimplifiedState;
Expand All @@ -422,7 +421,7 @@ function buildUiPlainTextMismatchMessage(args: {
function assertUiSnapshotSelectionMatches(
snapshot: UiMarkdownFuzzSnapshot,
state: SimplifiedState,
seed: string,
seed: number,
index: number,
operation: FuzzOperation,
): void {
Expand All @@ -446,7 +445,7 @@ function assertUiSnapshotSelectionMatches(
}

function buildUiSelectionMismatchMessage(args: {
seed: string;
seed: number;
index: number;
operation: FuzzOperation;
state: SimplifiedState;
Expand Down
16 changes: 14 additions & 2 deletions electron/ipc-lix.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,10 @@ export function registerLixIpc(resolveWindowForEvent, options = {}) {
const lix = await ensureLixOpenForEvent(event);
const sql = String(payload?.sql ?? "");
const params = normalizeParams(payload?.params);
const options = normalizeExecuteOptions(payload?.options);
const started = performance.now();
try {
const result = await lix.execute(sql, params);
const result = await lix.execute(sql, params, options);
const serialized = serializeExecuteResult(result, "lix.execute");
logSlowOperation("execute", started, {
sqlHash: hashString(sql),
Expand Down Expand Up @@ -130,9 +131,10 @@ export function registerLixIpc(resolveWindowForEvent, options = {}) {
);
const sql = String(payload?.sql ?? "");
const params = normalizeParams(payload?.params);
const options = normalizeExecuteOptions(payload?.options);
const started = performance.now();
try {
const result = await transaction.execute(sql, params);
const result = await transaction.execute(sql, params, options);
const serialized = serializeExecuteResult(result, "transaction.execute");
logSlowOperation("transaction:execute", started, {
transactionId: String(payload?.transactionId ?? ""),
Expand Down Expand Up @@ -449,6 +451,16 @@ function normalizeParams(params) {
return params.map((param, index) => normalizeSqlParam(param, index));
}

function normalizeExecuteOptions(options) {
if (!options || typeof options !== "object" || Array.isArray(options)) {
return undefined;
}
if (typeof options.originKey !== "string") {
return undefined;
}
return { originKey: options.originKey };
}

function normalizeSqlParam(value, index = 0) {
if (
value === null ||
Expand Down
10 changes: 6 additions & 4 deletions electron/lix.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,10 @@ function createDesktopLixHandle(nativeLix, workspaceDir, storageDir) {
storageDir() {
return storageDir;
},
async execute(sql, params = []) {
return await runQueued(() => nativeLix.execute(sql, [...params]));
async execute(sql, params = [], options) {
return await runQueued(() =>
nativeLix.execute(sql, [...params], options),
);
},
async beginTransaction() {
const releaseSlot = await acquireOperationSlot();
Expand All @@ -329,8 +331,8 @@ function createDesktopLixHandle(nativeLix, workspaceDir, storageDir) {
throw error;
}
return {
async execute(sql, params = []) {
return await transaction.execute(sql, [...params]);
async execute(sql, params = [], options) {
return await transaction.execute(sql, [...params], options);
},
async commit() {
if (transactionClosed) {
Expand Down
2 changes: 2 additions & 0 deletions electron/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export type DesktopLixApi = {
execute(payload: {
sql: string;
params?: ReadonlyArray<unknown>;
options?: { originKey?: string };
}): Promise<SerializedQueryResult>;
executeTransaction(payload: {
statements: ReadonlyArray<{
Expand All @@ -64,6 +65,7 @@ export type DesktopLixApi = {
transactionId: string;
sql: string;
params?: ReadonlyArray<unknown>;
options?: { originKey?: string };
}): Promise<SerializedQueryResult>;
transactionCommit(payload: { transactionId: string }): Promise<void>;
transactionRollback(payload: { transactionId: string }): Promise<void>;
Expand Down
39 changes: 38 additions & 1 deletion src/extensions/markdown/editor/build-markdown-from-editor.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,48 @@
import { serializeAst } from "./markdown";
import { tiptapDocToAst } from "./tiptap-markdown-bridge";

const createNodeId = (): string => {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID().replaceAll("-", "").slice(0, 10);
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`.slice(
0,
10,
);
};

function ensureTopLevelIds(children: any[]): void {
const seen = new Set<string>();
for (const node of children) {
node.data = node.data || {};
let id = (node.data.id ?? "") as string;
if (!id || seen.has(id)) {
do {
id = createNodeId();
} while (seen.has(id));
node.data.id = id;
}
seen.add(id);
}
}

export const normalizePersistedMarkdown = (markdown: string): string =>
markdown.endsWith("\n") ? markdown : `${markdown}\n`;

export function buildMarkdownFromEditor(editor: any): string {
const ast = tiptapDocToAst(editor.getJSON() as any) as any;
const children = (ast?.children ?? []) as any[];
ensureTopLevelIds(children);
const root = {
type: "root",
children: (ast?.children ?? []) as any[],
children,
} as any;
return serializeAst(root);
}

export function buildNormalizedMarkdownFromEditor(editor: any): string {
return normalizePersistedMarkdown(buildMarkdownFromEditor(editor));
}
59 changes: 16 additions & 43 deletions src/extensions/markdown/editor/create-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ import { Editor } from "@tiptap/core";
import History from "@tiptap/extension-history";
import Placeholder from "@tiptap/extension-placeholder";
import type { Lix } from "@/lib/lix-types";
import {
MarkdownWc,
astToTiptapDoc,
tiptapDocToAst,
} from "./tiptap-markdown-bridge";
import { MarkdownWc, astToTiptapDoc } from "./tiptap-markdown-bridge";
import type { EmptyMarkdownDefaultBlock } from "./tiptap-markdown-bridge";
import { parseMarkdown, serializeAst } from "./markdown";
import { handlePaste as defaultHandlePaste } from "./handle-paste";
import { SlashCommandsExtension } from "./extensions/slash-commands";
import { TableNavigationExtension } from "./extensions/table-navigation";
import { upsertMarkdownFile } from "./upsert-markdown-file";
import {
buildNormalizedMarkdownFromEditor,
normalizePersistedMarkdown,
} from "./build-markdown-from-editor";

type CreateEditorArgs = {
lix: Lix;
Expand All @@ -27,24 +27,21 @@ type CreateEditorArgs = {
persistDebounceMs?: number;
persistState?: boolean;
resolveImageSrc?: (src: string) => string;
originKey?: string;
};

const createNodeId = (): string => {
export const createMarkdownEditorOriginKey = (): string => {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID().replaceAll("-", "").slice(0, 10);
return `flashtype.markdown-editor:${crypto.randomUUID()}`;
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`.slice(
0,
10,
);
return `flashtype.markdown-editor:${Date.now().toString(36)}${Math.random()
.toString(36)
.slice(2)}`;
};

const normalizePersistedMarkdown = (markdown: string): string =>
markdown.endsWith("\n") ? markdown : `${markdown}\n`;

function flushEditorViewDomObserver(view: any): void {
view?.domObserver?.flush?.();
}
Expand All @@ -67,9 +64,7 @@ function externalLinkUrlFromClick(event: MouseEvent): string | null {
return null;
}
const target =
event.target instanceof Element
? event.target.closest("a[href]")
: null;
event.target instanceof Element ? event.target.closest("a[href]") : null;
if (!(target instanceof HTMLAnchorElement)) {
return null;
}
Expand Down Expand Up @@ -105,28 +100,6 @@ function handleExternalLinkClick(event: MouseEvent): void {
openExternalLink(url);
}

function ensureTopLevelIds(children: any[]): void {
const seen = new Set<string>();
for (const node of children) {
node.data = node.data || {};
let id = (node.data.id ?? "") as string;
if (!id || seen.has(id)) {
do {
id = createNodeId();
} while (seen.has(id));
node.data.id = id;
}
seen.add(id);
}
}

function markdownFromEditorAst(editor: Editor): string {
const ast = tiptapDocToAst(editor.getJSON() as any) as any;
const children: any[] = Array.isArray(ast?.children) ? ast.children : [];
ensureTopLevelIds(children);
return serializeAst({ type: "root", children } as any);
}

// Plain TipTap Editor factory (no React). Useful for unit/integration tests.
export function createEditor(args: CreateEditorArgs): Editor {
const {
Expand All @@ -142,6 +115,7 @@ export function createEditor(args: CreateEditorArgs): Editor {
persistDebounceMs,
persistState = true,
resolveImageSrc,
originKey = createMarkdownEditorOriginKey(),
} = args;

const ast = contentAst ?? (parseMarkdown(initialMarkdown ?? "") as any);
Expand All @@ -159,13 +133,14 @@ export function createEditor(args: CreateEditorArgs): Editor {
);
const persistDebounceMsResolved = persistDebounceMs ?? 0;
const persistOnce = async (editor: Editor) => {
const markdown = normalizePersistedMarkdown(markdownFromEditorAst(editor));
const markdown = buildNormalizedMarkdownFromEditor(editor);
if (markdown === lastPersistedMarkdown) return;
await upsertMarkdownFile({
lix,
fileId: fileId!,
markdown,
createIfMissing: false,
originKey,
});
lastPersistedMarkdown = markdown;
};
Expand Down Expand Up @@ -226,9 +201,7 @@ export function createEditor(args: CreateEditorArgs): Editor {
content: astToTiptapDoc(ast, { defaultBlock }) as any,
onCreate: ({ editor }) => {
currentEditor = editor as Editor;
lastPersistedMarkdown = normalizePersistedMarkdown(
markdownFromEditorAst(editor),
);
lastPersistedMarkdown = buildNormalizedMarkdownFromEditor(editor);
onCreate?.({ editor });
},
onUpdate: ({ editor }) => {
Expand Down
Loading
Loading