Add canvas interface (#461)
This commit is contained in:
parent
1a74a5ca9a
commit
b3cb0ea755
44 changed files with 7454 additions and 4691 deletions
39
lib/drizzle/0001_sparkling_blue_marvel.sql
Normal file
39
lib/drizzle/0001_sparkling_blue_marvel.sql
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
CREATE TABLE IF NOT EXISTS "Suggestion" (
|
||||
"id" uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
"documentId" uuid NOT NULL,
|
||||
"documentCreatedAt" timestamp NOT NULL,
|
||||
"originalText" text NOT NULL,
|
||||
"suggestedText" text NOT NULL,
|
||||
"description" text,
|
||||
"isResolved" boolean DEFAULT false NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"createdAt" timestamp NOT NULL,
|
||||
CONSTRAINT "Suggestion_id_pk" PRIMARY KEY("id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "Document" (
|
||||
"id" uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
"createdAt" timestamp NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"content" text,
|
||||
"userId" uuid NOT NULL,
|
||||
CONSTRAINT "Document_id_createdAt_pk" PRIMARY KEY("id","createdAt")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Suggestion" ADD CONSTRAINT "Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk" FOREIGN KEY ("documentId","documentCreatedAt") REFERENCES "public"."Document"("id","createdAt") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
259
lib/drizzle/meta/0001_snapshot.json
Normal file
259
lib/drizzle/meta/0001_snapshot.json
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
{
|
||||
"id": "f3d3437c-4735-4c91-80af-1014048a904e",
|
||||
"prevId": "715ec9ec-6715-4d0f-9f6c-9b5c7f09827c",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.Suggestion": {
|
||||
"name": "Suggestion",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"documentId": {
|
||||
"name": "documentId",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"documentCreatedAt": {
|
||||
"name": "documentCreatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"originalText": {
|
||||
"name": "originalText",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"suggestedText": {
|
||||
"name": "suggestedText",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"isResolved": {
|
||||
"name": "isResolved",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"Suggestion_userId_User_id_fk": {
|
||||
"name": "Suggestion_userId_User_id_fk",
|
||||
"tableFrom": "Suggestion",
|
||||
"tableTo": "User",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk": {
|
||||
"name": "Suggestion_documentId_documentCreatedAt_Document_id_createdAt_fk",
|
||||
"tableFrom": "Suggestion",
|
||||
"tableTo": "Document",
|
||||
"columnsFrom": [
|
||||
"documentId",
|
||||
"documentCreatedAt"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id",
|
||||
"createdAt"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"Suggestion_id_pk": {
|
||||
"name": "Suggestion_id_pk",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.Chat": {
|
||||
"name": "Chat",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"messages": {
|
||||
"name": "messages",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"Chat_userId_User_id_fk": {
|
||||
"name": "Chat_userId_User_id_fk",
|
||||
"tableFrom": "Chat",
|
||||
"tableTo": "User",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.Document": {
|
||||
"name": "Document",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"Document_userId_User_id_fk": {
|
||||
"name": "Document_userId_User_id_fk",
|
||||
"tableFrom": "Document",
|
||||
"tableTo": "User",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"Document_id_createdAt_pk": {
|
||||
"name": "Document_id_createdAt_pk",
|
||||
"columns": [
|
||||
"id",
|
||||
"createdAt"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"public.User": {
|
||||
"name": "User",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,13 @@
|
|||
"when": 1728598022383,
|
||||
"tag": "0000_keen_devos",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1730207363999,
|
||||
"tag": "0001_sparkling_blue_marvel",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
7
lib/editor/DiffType.js
Normal file
7
lib/editor/DiffType.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Taken from https://github.com/hamflx/prosemirror-diff/blob/master/src/DiffType.js
|
||||
|
||||
export const DiffType = {
|
||||
Unchanged: 0,
|
||||
Deleted: -1,
|
||||
Inserted: 1,
|
||||
};
|
||||
511
lib/editor/diff.js
Normal file
511
lib/editor/diff.js
Normal file
|
|
@ -0,0 +1,511 @@
|
|||
// Taken from https://github.com/hamflx/prosemirror-diff/blob/master/src/diff.js
|
||||
|
||||
import { diff_match_patch } from 'diff-match-patch';
|
||||
import { Fragment, Node } from 'prosemirror-model';
|
||||
|
||||
import { DiffType } from './DiffType.js';
|
||||
|
||||
export const patchDocumentNode = (schema, oldNode, newNode) => {
|
||||
assertNodeTypeEqual(oldNode, newNode);
|
||||
|
||||
const finalLeftChildren = [];
|
||||
const finalRightChildren = [];
|
||||
|
||||
const oldChildren = normalizeNodeContent(oldNode);
|
||||
const newChildren = normalizeNodeContent(newNode);
|
||||
const oldChildLen = oldChildren.length;
|
||||
const newChildLen = newChildren.length;
|
||||
const minChildLen = Math.min(oldChildLen, newChildLen);
|
||||
|
||||
let left = 0;
|
||||
let right = 0;
|
||||
|
||||
// console.log("==> searching same left");
|
||||
for (; left < minChildLen; left++) {
|
||||
const oldChild = oldChildren[left];
|
||||
const newChild = newChildren[left];
|
||||
if (!isNodeEqual(oldChild, newChild)) {
|
||||
break;
|
||||
}
|
||||
finalLeftChildren.push(...ensureArray(oldChild));
|
||||
}
|
||||
|
||||
// console.log("==> searching same right");
|
||||
for (; right + left + 1 < minChildLen; right++) {
|
||||
const oldChild = oldChildren[oldChildLen - right - 1];
|
||||
const newChild = newChildren[newChildLen - right - 1];
|
||||
if (!isNodeEqual(oldChild, newChild)) {
|
||||
break;
|
||||
}
|
||||
finalRightChildren.unshift(...ensureArray(oldChild));
|
||||
}
|
||||
|
||||
// console.log(
|
||||
// `==> eq left:${left}, right:${right}`,
|
||||
// [...finalLeftChildren],
|
||||
// [...finalRightChildren],
|
||||
// );
|
||||
|
||||
const diffOldChildren = oldChildren.slice(left, oldChildLen - right);
|
||||
const diffNewChildren = newChildren.slice(left, newChildLen - right);
|
||||
// console.log(
|
||||
// "==> diff children",
|
||||
// diffOldChildren.length,
|
||||
// diffNewChildren.length,
|
||||
// );
|
||||
if (diffOldChildren.length && diffNewChildren.length) {
|
||||
const matchedNodes = matchNodes(
|
||||
schema,
|
||||
diffOldChildren,
|
||||
diffNewChildren
|
||||
).sort((a, b) => b.count - a.count);
|
||||
const bestMatch = matchedNodes[0];
|
||||
if (bestMatch) {
|
||||
// console.log("==> bestMatch", bestMatch);
|
||||
const { oldStartIndex, newStartIndex, oldEndIndex, newEndIndex } =
|
||||
bestMatch;
|
||||
const oldBeforeMatchChildren = diffOldChildren.slice(0, oldStartIndex);
|
||||
const newBeforeMatchChildren = diffNewChildren.slice(0, newStartIndex);
|
||||
// console.log(
|
||||
// "==> before match",
|
||||
// oldBeforeMatchChildren.length,
|
||||
// newBeforeMatchChildren.length,
|
||||
// oldBeforeMatchChildren,
|
||||
// newBeforeMatchChildren,
|
||||
// );
|
||||
finalLeftChildren.push(
|
||||
...patchRemainNodes(
|
||||
schema,
|
||||
oldBeforeMatchChildren,
|
||||
newBeforeMatchChildren
|
||||
)
|
||||
);
|
||||
finalLeftChildren.push(
|
||||
...diffOldChildren.slice(oldStartIndex, oldEndIndex)
|
||||
);
|
||||
// console.log("==> match", oldEndIndex - oldStartIndex);
|
||||
|
||||
const oldAfterMatchChildren = diffOldChildren.slice(oldEndIndex);
|
||||
const newAfterMatchChildren = diffNewChildren.slice(newEndIndex);
|
||||
// console.log(
|
||||
// "==> after match",
|
||||
// oldAfterMatchChildren.length,
|
||||
// newAfterMatchChildren.length,
|
||||
// );
|
||||
finalRightChildren.unshift(
|
||||
...patchRemainNodes(
|
||||
schema,
|
||||
oldAfterMatchChildren,
|
||||
newAfterMatchChildren
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// console.log("==> no best match found");
|
||||
finalLeftChildren.push(
|
||||
...patchRemainNodes(schema, diffOldChildren, diffNewChildren)
|
||||
);
|
||||
}
|
||||
// console.log("==> matchedNodes", matchedNodes);
|
||||
} else {
|
||||
finalLeftChildren.push(
|
||||
...patchRemainNodes(schema, diffOldChildren, diffNewChildren)
|
||||
);
|
||||
}
|
||||
|
||||
return createNewNode(oldNode, [...finalLeftChildren, ...finalRightChildren]);
|
||||
};
|
||||
|
||||
const matchNodes = (schema, oldChildren, newChildren) => {
|
||||
// console.log("==> matchNodes", oldChildren, newChildren);
|
||||
const matches = [];
|
||||
for (
|
||||
let oldStartIndex = 0;
|
||||
oldStartIndex < oldChildren.length;
|
||||
oldStartIndex++
|
||||
) {
|
||||
const oldStartNode = oldChildren[oldStartIndex];
|
||||
const newStartIndex = findMatchNode(newChildren, oldStartNode);
|
||||
|
||||
if (newStartIndex !== -1) {
|
||||
let oldEndIndex = oldStartIndex + 1;
|
||||
let newEndIndex = newStartIndex + 1;
|
||||
for (
|
||||
;
|
||||
oldEndIndex < oldChildren.length && newEndIndex < newChildren.length;
|
||||
oldEndIndex++, newEndIndex++
|
||||
) {
|
||||
const oldEndNode = oldChildren[oldEndIndex];
|
||||
if (!isNodeEqual(newChildren[newEndIndex], oldEndNode)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// console.log(
|
||||
// "==> match",
|
||||
// oldStartIndex,
|
||||
// oldEndIndex,
|
||||
// newStartIndex,
|
||||
// newEndIndex,
|
||||
// );
|
||||
matches.push({
|
||||
oldStartIndex,
|
||||
newStartIndex,
|
||||
oldEndIndex,
|
||||
newEndIndex,
|
||||
count: newEndIndex - newStartIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
};
|
||||
|
||||
const findMatchNode = (children, node, startIndex = 0) => {
|
||||
for (let i = startIndex; i < children.length; i++) {
|
||||
if (isNodeEqual(children[i], node)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const patchRemainNodes = (schema, oldChildren, newChildren) => {
|
||||
const finalLeftChildren = [];
|
||||
const finalRightChildren = [];
|
||||
const oldChildLen = oldChildren.length;
|
||||
const newChildLen = newChildren.length;
|
||||
let left = 0;
|
||||
let right = 0;
|
||||
while (oldChildLen - left - right > 0 && newChildLen - left - right > 0) {
|
||||
const leftOldNode = oldChildren[left];
|
||||
const leftNewNode = newChildren[left];
|
||||
const rightOldNode = oldChildren[oldChildLen - right - 1];
|
||||
const rightNewNode = newChildren[newChildLen - right - 1];
|
||||
let updateLeft =
|
||||
!isTextNode(leftOldNode) && matchNodeType(leftOldNode, leftNewNode);
|
||||
let updateRight =
|
||||
!isTextNode(rightOldNode) && matchNodeType(rightOldNode, rightNewNode);
|
||||
if (Array.isArray(leftOldNode) && Array.isArray(leftNewNode)) {
|
||||
finalLeftChildren.push(
|
||||
...patchTextNodes(schema, leftOldNode, leftNewNode)
|
||||
);
|
||||
left += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (updateLeft && updateRight) {
|
||||
const equalityLeft = computeChildEqualityFactor(leftOldNode, leftNewNode);
|
||||
const equalityRight = computeChildEqualityFactor(
|
||||
rightOldNode,
|
||||
rightNewNode
|
||||
);
|
||||
if (equalityLeft < equalityRight) {
|
||||
updateLeft = false;
|
||||
} else {
|
||||
updateRight = false;
|
||||
}
|
||||
}
|
||||
if (updateLeft) {
|
||||
finalLeftChildren.push(
|
||||
patchDocumentNode(schema, leftOldNode, leftNewNode)
|
||||
);
|
||||
left += 1;
|
||||
} else if (updateRight) {
|
||||
finalRightChildren.unshift(
|
||||
patchDocumentNode(schema, rightOldNode, rightNewNode)
|
||||
);
|
||||
right += 1;
|
||||
} else {
|
||||
// todo
|
||||
finalLeftChildren.push(
|
||||
createDiffNode(schema, leftOldNode, DiffType.Deleted)
|
||||
);
|
||||
finalLeftChildren.push(
|
||||
createDiffNode(schema, leftNewNode, DiffType.Inserted)
|
||||
);
|
||||
left += 1;
|
||||
// delete and insert
|
||||
}
|
||||
}
|
||||
|
||||
const deleteNodeLen = oldChildLen - left - right;
|
||||
const insertNodeLen = newChildLen - left - right;
|
||||
if (deleteNodeLen) {
|
||||
finalLeftChildren.push(
|
||||
...oldChildren
|
||||
.slice(left, left + deleteNodeLen)
|
||||
.flat()
|
||||
.map((node) => createDiffNode(schema, node, DiffType.Deleted))
|
||||
);
|
||||
}
|
||||
|
||||
if (insertNodeLen) {
|
||||
finalRightChildren.unshift(
|
||||
...newChildren
|
||||
.slice(left, left + insertNodeLen)
|
||||
.flat()
|
||||
.map((node) => createDiffNode(schema, node, DiffType.Inserted))
|
||||
);
|
||||
}
|
||||
|
||||
return [...finalLeftChildren, ...finalRightChildren];
|
||||
};
|
||||
|
||||
export const patchTextNodes = (schema, oldNode, newNode) => {
|
||||
const dmp = new diff_match_patch();
|
||||
const oldText = oldNode.map((n) => getNodeText(n)).join('');
|
||||
const newText = newNode.map((n) => getNodeText(n)).join('');
|
||||
const diff = dmp.diff_main(oldText, newText);
|
||||
let oldLen = 0;
|
||||
let newLen = 0;
|
||||
const res = diff
|
||||
.map((d) => {
|
||||
const [type, content] = [d[0], d[1]];
|
||||
const node = createTextNode(
|
||||
schema,
|
||||
content,
|
||||
type !== DiffType.Unchanged ? createDiffMark(schema, type) : []
|
||||
);
|
||||
const oldFrom = oldLen;
|
||||
const oldTo = oldFrom + (type === DiffType.Inserted ? 0 : content.length);
|
||||
const newFrom = newLen;
|
||||
const newTo = newFrom + (type === DiffType.Deleted ? 0 : content.length);
|
||||
oldLen = oldTo;
|
||||
newLen = newTo;
|
||||
return { node, type, oldFrom, oldTo, newFrom, newTo };
|
||||
})
|
||||
.map(({ node, type, oldFrom, oldTo, newFrom, newTo }) => {
|
||||
if (type === DiffType.Deleted) {
|
||||
const textItems = findTextNodes(oldNode, oldFrom, oldTo).filter(
|
||||
(n) => Object.keys(n.node.attrs ?? {}).length || n.node.marks?.length
|
||||
);
|
||||
return applyTextNodeAttrsMarks(schema, node, oldFrom, textItems);
|
||||
} else {
|
||||
const textItems = findTextNodes(newNode, newFrom, newTo).filter(
|
||||
(n) => Object.keys(n.node.attrs ?? {}).length || n.node.marks?.length
|
||||
);
|
||||
return applyTextNodeAttrsMarks(schema, node, newFrom, textItems);
|
||||
}
|
||||
});
|
||||
return res.flat(Infinity);
|
||||
};
|
||||
|
||||
const findTextNodes = (textNodes, from, to) => {
|
||||
const result = [];
|
||||
let start = 0;
|
||||
for (let i = 0; i < textNodes.length && start < to; i++) {
|
||||
const node = textNodes[i];
|
||||
const text = getNodeText(node);
|
||||
const end = start + text.length;
|
||||
const intersect =
|
||||
(start >= from && start < to) ||
|
||||
(end > from && end <= to) ||
|
||||
(start <= from && end >= to);
|
||||
if (intersect) {
|
||||
result.push({ node, from: start, to: end });
|
||||
}
|
||||
start += text.length;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const applyTextNodeAttrsMarks = (schema, node, base, textItems) => {
|
||||
if (!textItems.length) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const baseMarks = node.marks ?? [];
|
||||
const firstItem = textItems[0];
|
||||
const nodeText = getNodeText(node);
|
||||
const nodeEnd = base + nodeText.length;
|
||||
const result = [];
|
||||
if (firstItem.from - base > 0) {
|
||||
result.push(
|
||||
createTextNode(
|
||||
schema,
|
||||
nodeText.slice(0, firstItem.from - base),
|
||||
baseMarks
|
||||
)
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < textItems.length; i++) {
|
||||
const { from, node: textNode, to } = textItems[i];
|
||||
result.push(
|
||||
createTextNode(
|
||||
schema,
|
||||
nodeText.slice(Math.max(from, base) - base, to - base),
|
||||
[...baseMarks, ...(textNode.marks ?? [])]
|
||||
)
|
||||
);
|
||||
const nextFrom = i + 1 < textItems.length ? textItems[i + 1].from : nodeEnd;
|
||||
if (nextFrom > to) {
|
||||
result.push(
|
||||
createTextNode(
|
||||
schema,
|
||||
nodeText.slice(to - base, nextFrom - base),
|
||||
baseMarks
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const computeChildEqualityFactor = (node1, node2) => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const assertNodeTypeEqual = (node1, node2) => {
|
||||
if (getNodeProperty(node1, 'type') !== getNodeProperty(node2, 'type')) {
|
||||
throw new Error(`node type not equal: ${node1.type} !== ${node2.type}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const ensureArray = (value) => {
|
||||
return Array.isArray(value) ? value : [value];
|
||||
};
|
||||
|
||||
export const isNodeEqual = (node1, node2) => {
|
||||
const isNode1Array = Array.isArray(node1);
|
||||
const isNode2Array = Array.isArray(node2);
|
||||
if (isNode1Array !== isNode2Array) {
|
||||
return false;
|
||||
}
|
||||
if (isNode1Array) {
|
||||
return (
|
||||
node1.length === node2.length &&
|
||||
node1.every((node, index) => isNodeEqual(node, node2[index]))
|
||||
);
|
||||
}
|
||||
|
||||
const type1 = getNodeProperty(node1, 'type');
|
||||
const type2 = getNodeProperty(node2, 'type');
|
||||
if (type1 !== type2) {
|
||||
return false;
|
||||
}
|
||||
if (isTextNode(node1)) {
|
||||
const text1 = getNodeProperty(node1, 'text');
|
||||
const text2 = getNodeProperty(node2, 'text');
|
||||
if (text1 !== text2) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const attrs1 = getNodeAttributes(node1);
|
||||
const attrs2 = getNodeAttributes(node2);
|
||||
const attrs = [...new Set([...Object.keys(attrs1), ...Object.keys(attrs2)])];
|
||||
for (const attr of attrs) {
|
||||
if (attrs1[attr] !== attrs2[attr]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const marks1 = getNodeMarks(node1);
|
||||
const marks2 = getNodeMarks(node2);
|
||||
if (marks1.length !== marks2.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < marks1.length; i++) {
|
||||
if (!isNodeEqual(marks1[i], marks2[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const children1 = getNodeChildren(node1);
|
||||
const children2 = getNodeChildren(node2);
|
||||
if (children1.length !== children2.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < children1.length; i++) {
|
||||
if (!isNodeEqual(children1[i], children2[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const normalizeNodeContent = (node) => {
|
||||
const content = getNodeChildren(node) ?? [];
|
||||
const res = [];
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const child = content[i];
|
||||
if (isTextNode(child)) {
|
||||
const textNodes = [];
|
||||
for (
|
||||
let textNode = content[i];
|
||||
i < content.length && isTextNode(textNode);
|
||||
textNode = content[++i]
|
||||
) {
|
||||
textNodes.push(textNode);
|
||||
}
|
||||
i--;
|
||||
res.push(textNodes);
|
||||
} else {
|
||||
res.push(child);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getNodeProperty = (node, property) => {
|
||||
if (property === 'type') {
|
||||
return node.type?.name;
|
||||
}
|
||||
return node[property];
|
||||
};
|
||||
export const getNodeAttribute = (node, attribute) =>
|
||||
node.attrs ? node.attrs[attribute] : undefined;
|
||||
export const getNodeAttributes = (node) =>
|
||||
node.attrs ? node.attrs : undefined;
|
||||
export const getNodeMarks = (node) => node.marks ?? [];
|
||||
export const getNodeChildren = (node) => node.content?.content ?? [];
|
||||
export const getNodeText = (node) => node.text;
|
||||
export const isTextNode = (node) => node.type?.name === 'text';
|
||||
export const matchNodeType = (node1, node2) =>
|
||||
node1.type?.name === node2.type?.name ||
|
||||
(Array.isArray(node1) && Array.isArray(node2));
|
||||
export const createNewNode = (oldNode, children) => {
|
||||
if (!oldNode.type) {
|
||||
throw new Error('oldNode.type is undefined');
|
||||
}
|
||||
return new Node(
|
||||
oldNode.type,
|
||||
oldNode.attrs,
|
||||
Fragment.fromArray(children),
|
||||
oldNode.marks
|
||||
);
|
||||
};
|
||||
export const createDiffNode = (schema, node, type) => {
|
||||
return mapDocumentNode(node, (node) => {
|
||||
if (isTextNode(node)) {
|
||||
return createTextNode(schema, getNodeText(node), [
|
||||
...(node.marks || []),
|
||||
createDiffMark(schema, type),
|
||||
]);
|
||||
}
|
||||
return node;
|
||||
});
|
||||
};
|
||||
function mapDocumentNode(node, mapper) {
|
||||
const copy = node.copy(
|
||||
Fragment.from(
|
||||
node.content.content
|
||||
.map((node) => mapDocumentNode(node, mapper))
|
||||
.filter((n) => n)
|
||||
)
|
||||
);
|
||||
return mapper(copy) || copy;
|
||||
}
|
||||
export const createDiffMark = (schema, type) => {
|
||||
if (type === DiffType.Inserted) {
|
||||
return schema.mark('diffMark', { type });
|
||||
}
|
||||
if (type === DiffType.Deleted) {
|
||||
return schema.mark('diffMark', { type });
|
||||
}
|
||||
throw new Error('type is not valid');
|
||||
};
|
||||
export const createTextNode = (schema, content, marks = []) => {
|
||||
return schema.text(content, marks);
|
||||
};
|
||||
|
||||
export const diffEditor = (schema, oldDoc, newDoc) => {
|
||||
const oldNode = Node.fromJSON(schema, oldDoc);
|
||||
const newNode = Node.fromJSON(schema, newDoc);
|
||||
return patchDocumentNode(schema, oldNode, newNode);
|
||||
};
|
||||
2
lib/editor/index.js
Normal file
2
lib/editor/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./diff.js";
|
||||
export * from "./DiffType";
|
||||
11
lib/editor/react-renderer.tsx
Normal file
11
lib/editor/react-renderer.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { createRoot } from "react-dom/client";
|
||||
|
||||
export class ReactRenderer {
|
||||
static render(component: React.ReactElement, dom: HTMLElement) {
|
||||
const root = createRoot(dom);
|
||||
root.render(component);
|
||||
return {
|
||||
destroy: () => root.unmount(),
|
||||
};
|
||||
}
|
||||
}
|
||||
118
lib/editor/suggestions.tsx
Normal file
118
lib/editor/suggestions.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { Node } from 'prosemirror-model';
|
||||
import { PluginKey, Plugin } from 'prosemirror-state';
|
||||
import { DecorationSet, EditorView } from 'prosemirror-view';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { Suggestion as PreviewSuggestion } from '@/components/custom/suggestion';
|
||||
import { Suggestion } from '@/db/schema';
|
||||
|
||||
export interface UISuggestion extends Suggestion {
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
}
|
||||
|
||||
interface Position {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function findPositionsInDoc(doc: Node, searchText: string): Position | null {
|
||||
let positions: { start: number; end: number } | null = null;
|
||||
|
||||
doc.nodesBetween(0, doc.content.size, (node, pos) => {
|
||||
if (node.isText && node.text) {
|
||||
const index = node.text.indexOf(searchText);
|
||||
|
||||
if (index !== -1) {
|
||||
positions = {
|
||||
start: pos + index,
|
||||
end: pos + index + searchText.length,
|
||||
};
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function projectWithHighlights(
|
||||
doc: Node,
|
||||
suggestions: Array<Suggestion>
|
||||
): Array<UISuggestion> {
|
||||
return suggestions.map((suggestion) => {
|
||||
const positions = findPositionsInDoc(doc, suggestion.originalText);
|
||||
|
||||
if (!positions) {
|
||||
return {
|
||||
...suggestion,
|
||||
selectionStart: 0,
|
||||
selectionEnd: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...suggestion,
|
||||
selectionStart: positions.start,
|
||||
selectionEnd: positions.end,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function createSuggestionWidget(
|
||||
suggestion: UISuggestion,
|
||||
view: EditorView
|
||||
): { dom: HTMLElement; destroy: () => void } {
|
||||
const dom = document.createElement('span');
|
||||
const root = createRoot(dom);
|
||||
|
||||
const onApply = () => {
|
||||
const { state, dispatch } = view;
|
||||
const tr = state.tr.replaceWith(
|
||||
suggestion.selectionStart,
|
||||
suggestion.selectionEnd,
|
||||
state.schema.text(suggestion.suggestedText)
|
||||
);
|
||||
|
||||
dispatch(tr);
|
||||
};
|
||||
|
||||
root.render(<PreviewSuggestion suggestion={suggestion} onApply={onApply} />);
|
||||
|
||||
return {
|
||||
dom,
|
||||
destroy: () => {
|
||||
// Wrapping unmount in setTimeout to avoid synchronous unmounting during render
|
||||
setTimeout(() => {
|
||||
root.unmount();
|
||||
}, 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const suggestionsPluginKey = new PluginKey('suggestions');
|
||||
export const suggestionsPlugin = new Plugin({
|
||||
key: suggestionsPluginKey,
|
||||
state: {
|
||||
init() {
|
||||
return { decorations: DecorationSet.empty, selected: null };
|
||||
},
|
||||
apply(tr, state) {
|
||||
const newDecorations = tr.getMeta(suggestionsPluginKey);
|
||||
if (newDecorations) return newDecorations;
|
||||
|
||||
return {
|
||||
decorations: state.decorations.map(tr.mapping, tr.doc),
|
||||
selected: state.selected,
|
||||
};
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state)?.decorations ?? DecorationSet.empty;
|
||||
},
|
||||
},
|
||||
});
|
||||
17
lib/model.ts
17
lib/model.ts
|
|
@ -1,17 +0,0 @@
|
|||
// Define your models here.
|
||||
export const models = [
|
||||
{
|
||||
label: 'GPT 4o mini',
|
||||
name: 'gpt-4o-mini',
|
||||
description: 'Small model for fast, lightweight tasks',
|
||||
},
|
||||
{
|
||||
label: 'GPT 4o',
|
||||
name: 'gpt-4o',
|
||||
description: 'For complex, multi-step tasks',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_MODEL_NAME: Model['name'] = 'gpt-4o-mini';
|
||||
|
||||
export type Model = (typeof models)[number];
|
||||
123
lib/utils.ts
123
lib/utils.ts
|
|
@ -1,14 +1,15 @@
|
|||
import {
|
||||
CoreAssistantMessage,
|
||||
CoreMessage,
|
||||
CoreToolMessage,
|
||||
generateId,
|
||||
Message,
|
||||
ToolInvocation,
|
||||
} from "ai";
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
} from 'ai';
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { Chat } from "@/db/schema";
|
||||
import { Chat } from '@/db/schema';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
|
|
@ -24,7 +25,7 @@ export const fetcher = async (url: string) => {
|
|||
|
||||
if (!res.ok) {
|
||||
const error = new Error(
|
||||
"An error occurred while fetching the data.",
|
||||
'An error occurred while fetching the data.'
|
||||
) as ApplicationError;
|
||||
|
||||
error.info = await res.json();
|
||||
|
|
@ -37,16 +38,16 @@ export const fetcher = async (url: string) => {
|
|||
};
|
||||
|
||||
export function getLocalStorage(key: string) {
|
||||
if (typeof window !== "undefined") {
|
||||
return JSON.parse(localStorage.getItem(key) || "[]");
|
||||
if (typeof window !== 'undefined') {
|
||||
return JSON.parse(localStorage.getItem(key) || '[]');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function generateUUID(): string {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
|
@ -64,13 +65,13 @@ function addToolMessageToChat({
|
|||
...message,
|
||||
toolInvocations: message.toolInvocations.map((toolInvocation) => {
|
||||
const toolResult = toolMessage.content.find(
|
||||
(tool) => tool.toolCallId === toolInvocation.toolCallId,
|
||||
(tool) => tool.toolCallId === toolInvocation.toolCallId
|
||||
);
|
||||
|
||||
if (toolResult) {
|
||||
return {
|
||||
...toolInvocation,
|
||||
state: "result",
|
||||
state: 'result',
|
||||
result: toolResult.result,
|
||||
};
|
||||
}
|
||||
|
|
@ -85,28 +86,28 @@ function addToolMessageToChat({
|
|||
}
|
||||
|
||||
export function convertToUIMessages(
|
||||
messages: Array<CoreMessage>,
|
||||
messages: Array<CoreMessage>
|
||||
): Array<Message> {
|
||||
return messages.reduce((chatMessages: Array<Message>, message) => {
|
||||
if (message.role === "tool") {
|
||||
if (message.role === 'tool') {
|
||||
return addToolMessageToChat({
|
||||
toolMessage: message as CoreToolMessage,
|
||||
messages: chatMessages,
|
||||
});
|
||||
}
|
||||
|
||||
let textContent = "";
|
||||
let textContent = '';
|
||||
let toolInvocations: Array<ToolInvocation> = [];
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
if (typeof message.content === 'string') {
|
||||
textContent = message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const content of message.content) {
|
||||
if (content.type === "text") {
|
||||
if (content.type === 'text') {
|
||||
textContent += content.text;
|
||||
} else if (content.type === "tool-call") {
|
||||
} else if (content.type === 'tool-call') {
|
||||
toolInvocations.push({
|
||||
state: "call",
|
||||
state: 'call',
|
||||
toolCallId: content.toolCallId,
|
||||
toolName: content.toolName,
|
||||
args: content.args,
|
||||
|
|
@ -131,8 +132,92 @@ export function getTitleFromChat(chat: Chat) {
|
|||
const firstMessage = messages[0];
|
||||
|
||||
if (!firstMessage) {
|
||||
return "Untitled";
|
||||
return 'Untitled';
|
||||
}
|
||||
|
||||
return firstMessage.content;
|
||||
}
|
||||
|
||||
const emptyAssistantMessage = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function sanitizeResponseMessages(
|
||||
messages: Array<CoreToolMessage | CoreAssistantMessage>
|
||||
): Array<CoreToolMessage | CoreAssistantMessage> {
|
||||
let toolResultIds: Array<string> = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === 'tool') {
|
||||
for (const content of message.content) {
|
||||
if (content.type === 'tool-result') {
|
||||
toolResultIds.push(content.toolCallId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const messagesBySanitizedContent = messages.map((message) => {
|
||||
if (message.role !== 'assistant') return message;
|
||||
|
||||
if (typeof message.content === 'string') return message;
|
||||
|
||||
const sanitizedContent = message.content.filter((content) =>
|
||||
content.type === 'tool-call'
|
||||
? toolResultIds.includes(content.toolCallId)
|
||||
: content.type === 'text'
|
||||
? content.text.length > 0
|
||||
: true
|
||||
);
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: sanitizedContent,
|
||||
};
|
||||
});
|
||||
|
||||
return messagesBySanitizedContent.filter(
|
||||
(message) => message.content.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function sanitizeUIMessages(messages: Array<Message>): Array<Message> {
|
||||
const messagesBySanitizedToolInvocations = messages.map((message) => {
|
||||
if (message.role !== 'assistant') return message;
|
||||
|
||||
if (!message.toolInvocations) return message;
|
||||
|
||||
let toolResultIds: Array<string> = [];
|
||||
|
||||
for (const toolInvocation of message.toolInvocations) {
|
||||
if (toolInvocation.state === 'result') {
|
||||
toolResultIds.push(toolInvocation.toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedToolInvocations = message.toolInvocations.filter(
|
||||
(toolInvocation) =>
|
||||
toolInvocation.state === 'result' ||
|
||||
toolResultIds.includes(toolInvocation.toolCallId)
|
||||
);
|
||||
|
||||
return {
|
||||
...message,
|
||||
toolInvocations: sanitizedToolInvocations,
|
||||
};
|
||||
});
|
||||
|
||||
return messagesBySanitizedToolInvocations.filter(
|
||||
(message) =>
|
||||
message.content.length > 0 ||
|
||||
(message.toolInvocations && message.toolInvocations.length > 0)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue