aboutsummaryrefslogtreecommitdiff
path: root/apps/web/lib
diff options
context:
space:
mode:
authorDhravya Shah <[email protected]>2024-07-28 10:58:22 -0700
committerDhravya Shah <[email protected]>2024-07-28 10:58:22 -0700
commita3f551e55768066cbb13ea1ce53a7a1e0a64d088 (patch)
treef10eeb2b9894948c2dbdf52001ec4f842490c4ca /apps/web/lib
parentlockfile (diff)
parentMerge branch 'main' into canvas (diff)
downloadsupermemory-a3f551e55768066cbb13ea1ce53a7a1e0a64d088.tar.xz
supermemory-a3f551e55768066cbb13ea1ce53a7a1e0a64d088.zip
merged
Diffstat (limited to 'apps/web/lib')
-rw-r--r--apps/web/lib/ExternalDroppedContent.ts134
-rw-r--r--apps/web/lib/createEmbeds.ts161
-rw-r--r--apps/web/lib/drophelpers.ts67
-rw-r--r--apps/web/lib/loadSnap.ts5
-rw-r--r--apps/web/lib/unfirlsite.ts53
5 files changed, 268 insertions, 152 deletions
diff --git a/apps/web/lib/ExternalDroppedContent.ts b/apps/web/lib/ExternalDroppedContent.ts
new file mode 100644
index 00000000..1333cf20
--- /dev/null
+++ b/apps/web/lib/ExternalDroppedContent.ts
@@ -0,0 +1,134 @@
+import { Editor } from "tldraw";
+import createEmbedsFromUrl from "./createEmbeds";
+
+function processURL(input: string): string | null {
+ let str = input.trim();
+ if (!/^(?:f|ht)tps?:\/\//i.test(str)) {
+ str = "http://" + str;
+ }
+ try {
+ const url = new URL(str);
+ return url.href;
+ } catch {
+ return str.match(
+ /^(https?:\/\/)?(www\.)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(\/.*)?$/i,
+ )
+ ? str
+ : null;
+ }
+}
+
+function formatContent(title: string, content: string): string {
+ const totalLength = title.length + content.length;
+ const totalHeight = Math.ceil(totalLength * (2 / 3));
+ const titleLines = Math.ceil(totalHeight * (2 / 5));
+ const contentLines = totalHeight - titleLines;
+
+ const titleWithNewLines = title + "\n".repeat(titleLines);
+ const contentWithNewLines = content + "\n".repeat(contentLines);
+
+ return `${titleWithNewLines.trim()}\n\n${contentWithNewLines.trim()}`;
+}
+
+function formatTextToRatio(text: string): { height: number; width: number } {
+ const RATIO = 4 / 3;
+ const FONT_SIZE = 15;
+ const CHAR_WIDTH = FONT_SIZE * 0.6;
+ const LINE_HEIGHT = FONT_SIZE * 1.2;
+ const MIN_WIDTH = 200;
+
+ let width = Math.min(
+ 800,
+ Math.max(MIN_WIDTH, Math.ceil(text.length * CHAR_WIDTH)),
+ );
+
+ width = Math.ceil(width / 4) * 4;
+
+ const maxLineWidth = Math.floor(width / CHAR_WIDTH);
+
+ const words = text.split(" ");
+ let lines: string[] = [];
+ let currentLine = "";
+
+ words.forEach((word) => {
+ if ((currentLine + word).length <= maxLineWidth) {
+ currentLine += (currentLine ? " " : "") + word;
+ } else {
+ lines.push(currentLine);
+ currentLine = word;
+ }
+ });
+ if (currentLine) {
+ lines.push(currentLine);
+ }
+
+ let height = Math.ceil(lines.length * LINE_HEIGHT);
+
+ if (width / height > RATIO) {
+ width = Math.ceil(height * RATIO);
+ } else {
+ height = Math.ceil(width / RATIO);
+ }
+
+ return { height, width };
+}
+
+type CardData = {
+ title: string;
+ type: string;
+ content: string;
+ text: boolean;
+};
+
+type DroppedData = CardData | string | { imageUrl: string };
+
+export function handleExternalDroppedContent({
+ droppedData,
+ editor,
+}: {
+ droppedData: DroppedData;
+ editor: Editor;
+}) {
+ const position = editor.inputs.shiftKey
+ ? editor.inputs.currentPagePoint
+ : editor.getViewportPageBounds().center;
+
+ if (typeof droppedData === "string") {
+ const processedURL = processURL(droppedData);
+ if (processedURL) {
+ createEmbedsFromUrl({ editor, url: processedURL });
+ } else {
+ createTextCard(editor, position, droppedData, "String Content");
+ }
+ } else if ("imageUrl" in droppedData) {
+ } else {
+ const { content, title, type, text } = droppedData;
+ if (text) {
+ const { height, width } = formatTextToRatio(content);
+ editor.createShape({
+ type: "text",
+ x: position.x - width / 2,
+ y: position.y - height / 2,
+ props: { text: formatContent(title, content) },
+ });
+ } else {
+ createTextCard(editor, position, title, type, content);
+ }
+ }
+}
+
+function createTextCard(
+ editor: Editor,
+ position: { x: number; y: number },
+ content: string,
+ type: string,
+ extraInfo: string = "",
+) {
+ const { height, width } = formatTextToRatio(content);
+ editor.createShape({
+ type: "Textcard",
+ x: position.x - width / 2,
+ y: position.y - height / 2,
+ props: { content, type, extrainfo: extraInfo, h: 200, w: 400 },
+ });
+}
diff --git a/apps/web/lib/createEmbeds.ts b/apps/web/lib/createEmbeds.ts
index 75347d31..e3f67340 100644
--- a/apps/web/lib/createEmbeds.ts
+++ b/apps/web/lib/createEmbeds.ts
@@ -1,19 +1,15 @@
-// @ts-nocheck TODO: A LOT OF TS ERRORS HERE
-
import {
AssetRecordType,
Editor,
TLAsset,
TLAssetId,
- TLBookmarkShape,
TLExternalContentSource,
- TLShapePartial,
- Vec,
VecLike,
- createShapeId,
getEmbedInfo,
getHashForString,
} from "tldraw";
+import { createEmptyBookmarkShape } from "./drophelpers";
+import { unfirlSite } from "@/app/actions/fetchers";
export default async function createEmbedsFromUrl({
url,
@@ -32,7 +28,8 @@ export default async function createEmbedsFromUrl({
? editor.inputs.currentPagePoint
: editor.getViewportPageBounds().center);
- if (url?.includes("x.com") || url?.includes("twitter.com")) {
+ const urlPattern = /https?:\/\/(x\.com|twitter\.com)\/[\w]+\/[\w]+\/[\d]+/;
+ if (urlPattern.test(url)) {
return editor.createShape({
type: "Twittercard",
x: position.x - 250,
@@ -56,7 +53,6 @@ export default async function createEmbedsFromUrl({
const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url));
const shape = createEmptyBookmarkShape(editor, url, position);
- // Use an existing asset if we have one, or else else create a new one
let asset = editor.getAsset(assetId) as TLAsset;
let shouldAlsoCreateAsset = false;
if (!asset) {
@@ -66,20 +62,14 @@ export default async function createEmbedsFromUrl({
type: "url",
url,
});
- const fetchWebsite: {
- title?: string;
- image?: string;
- description?: string;
- } = await (
- await fetch(`/api/unfirlsite?website=${url}`, {
- method: "POST",
- })
- ).json();
+ const value = await unfirlSite(url);
if (bookmarkAsset) {
- if (fetchWebsite.title) bookmarkAsset.props.title = fetchWebsite.title;
- if (fetchWebsite.image) bookmarkAsset.props.image = fetchWebsite.image;
- if (fetchWebsite.description)
- bookmarkAsset.props.description = fetchWebsite.description;
+ if (bookmarkAsset.type === "bookmark") {
+ if (value.title) bookmarkAsset.props.title = value.title;
+ if (value.image) bookmarkAsset.props.image = value.image;
+ if (value.description)
+ bookmarkAsset.props.description = value.description;
+ }
}
if (!bookmarkAsset) throw Error("Could not create an asset");
asset = bookmarkAsset;
@@ -105,132 +95,3 @@ export default async function createEmbedsFromUrl({
]);
});
}
-
-function isURL(str: string) {
- try {
- new URL(str);
- return true;
- } catch {
- return false;
- }
-}
-
-function formatTextToRatio(text: string) {
- const totalWidth = text.length;
- const maxLineWidth = Math.floor(totalWidth / 10);
-
- const words = text.split(" ");
- let lines = [];
- let currentLine = "";
-
- words.forEach((word) => {
- if ((currentLine + word).length <= maxLineWidth) {
- currentLine += (currentLine ? " " : "") + word;
- } else {
- lines.push(currentLine);
- currentLine = word;
- }
- });
- if (currentLine) {
- lines.push(currentLine);
- }
- return { height: (lines.length + 1) * 18, width: maxLineWidth * 10 };
-}
-
-export function handleExternalDroppedContent({
- text,
- editor,
-}: {
- text: string;
- editor: Editor;
-}) {
- const position = editor.inputs.shiftKey
- ? editor.inputs.currentPagePoint
- : editor.getViewportPageBounds().center;
-
- if (isURL(text)) {
- createEmbedsFromUrl({ editor, url: text });
- } else {
- // editor.createShape({
- // type: "text",
- // x: position.x - 75,
- // y: position.y - 75,
- // props: {
- // text: text,
- // size: "s",
- // textAlign: "start",
- // },
- // });
- const { height, width } = formatTextToRatio(text);
- editor.createShape({
- type: "Textcard",
- x: position.x - width / 2,
- y: position.y - height / 2,
- props: {
- content: text,
- extrainfo: "https://chatgpt.com/c/762cd44e-1752-495b-967a-aa3c23c6024a",
- w: width,
- h: height,
- },
- });
- }
-}
-
-function centerSelectionAroundPoint(editor: Editor, position: VecLike) {
- // Re-position shapes so that the center of the group is at the provided point
- const viewportPageBounds = editor.getViewportPageBounds();
- let selectionPageBounds = editor.getSelectionPageBounds();
-
- if (selectionPageBounds) {
- const offset = selectionPageBounds!.center.sub(position);
-
- editor.updateShapes(
- editor.getSelectedShapes().map((shape) => {
- const localRotation = editor
- .getShapeParentTransform(shape)
- .decompose().rotation;
- const localDelta = Vec.Rot(offset, -localRotation);
- return {
- id: shape.id,
- type: shape.type,
- x: shape.x! - localDelta.x,
- y: shape.y! - localDelta.y,
- };
- }),
- );
- }
-
- // Zoom out to fit the shapes, if necessary
- selectionPageBounds = editor.getSelectionPageBounds();
- if (
- selectionPageBounds &&
- !viewportPageBounds.contains(selectionPageBounds)
- ) {
- editor.zoomToSelection();
- }
-}
-
-export function createEmptyBookmarkShape(
- editor: Editor,
- url: string,
- position: VecLike,
-): TLBookmarkShape {
- const partial: TLShapePartial = {
- id: createShapeId(),
- type: "bookmark",
- x: position.x - 150,
- y: position.y - 160,
- opacity: 1,
- props: {
- assetId: null,
- url,
- },
- };
-
- editor.batch(() => {
- editor.createShapes([partial]).select(partial.id);
- centerSelectionAroundPoint(editor, position);
- });
-
- return editor.getShape(partial.id) as TLBookmarkShape;
-}
diff --git a/apps/web/lib/drophelpers.ts b/apps/web/lib/drophelpers.ts
new file mode 100644
index 00000000..9094da6e
--- /dev/null
+++ b/apps/web/lib/drophelpers.ts
@@ -0,0 +1,67 @@
+import {
+ Editor,
+ TLBookmarkShape,
+ TLShapePartial,
+ Vec,
+ VecLike,
+ createShapeId,
+} from "tldraw";
+
+export function createEmptyBookmarkShape(
+ editor: Editor,
+ url: string,
+ position: VecLike,
+): TLBookmarkShape {
+ const partial: TLShapePartial = {
+ id: createShapeId(),
+ type: "bookmark",
+ x: position.x - 150,
+ y: position.y - 160,
+ opacity: 1,
+ props: {
+ assetId: null,
+ url,
+ },
+ };
+
+ editor.batch(() => {
+ editor.createShapes([partial]).select(partial.id);
+ centerSelectionAroundPoint(editor, position);
+ });
+
+ return editor.getShape(partial.id) as TLBookmarkShape;
+}
+
+function centerSelectionAroundPoint(editor: Editor, position: VecLike) {
+ // Re-position shapes so that the center of the group is at the provided point
+ const viewportPageBounds = editor.getViewportPageBounds();
+ let selectionPageBounds = editor.getSelectionPageBounds();
+
+ if (selectionPageBounds) {
+ const offset = selectionPageBounds!.center.sub(position);
+
+ editor.updateShapes(
+ editor.getSelectedShapes().map((shape) => {
+ const localRotation = editor
+ .getShapeParentTransform(shape)
+ .decompose().rotation;
+ const localDelta = Vec.Rot(offset, -localRotation);
+ return {
+ id: shape.id,
+ type: shape.type,
+ x: shape.x! - localDelta.x,
+ y: shape.y! - localDelta.y,
+ };
+ }),
+ );
+ }
+
+ // Zoom out to fit the shapes, if necessary
+ selectionPageBounds = editor.getSelectionPageBounds();
+ if (
+ selectionPageBounds &&
+ !viewportPageBounds.contains(selectionPageBounds)
+ ) {
+ editor.zoomToSelection();
+ }
+}
diff --git a/apps/web/lib/loadSnap.ts b/apps/web/lib/loadSnap.ts
index bcf81eca..2d3cd24e 100644
--- a/apps/web/lib/loadSnap.ts
+++ b/apps/web/lib/loadSnap.ts
@@ -1,7 +1,8 @@
import { createTLStore, defaultShapeUtils, loadSnapshot } from "tldraw";
import { getCanvasData } from "../app/actions/fetchers";
-import { twitterCardUtil } from "../components/canvas/twitterCard";
-import { textCardUtil } from "../components/canvas/textCard";
+// import { twitterCardUtil } from "../components/canvas/custom_nodes/twitterCard";
+import { twitterCardUtil } from "@/components/canvas/custom_nodes/twittercard";
+import { textCardUtil } from "@/components/canvas/custom_nodes/textcard";
export async function loadRemoteSnapshot(id: string) {
const snapshot = await getCanvasData(id);
diff --git a/apps/web/lib/unfirlsite.ts b/apps/web/lib/unfirlsite.ts
new file mode 100644
index 00000000..a7a77c0c
--- /dev/null
+++ b/apps/web/lib/unfirlsite.ts
@@ -0,0 +1,53 @@
+import cheerio from "cheerio";
+
+export async function unfurl(url: string) {
+ const response = await fetch(url);
+ if (response.status >= 400) {
+ throw new Error(`Error fetching url: ${response.status}`);
+ }
+ const contentType = response.headers.get("content-type");
+ if (!contentType?.includes("text/html")) {
+ throw new Error(`Content-type not right: ${contentType}`);
+ }
+
+ const content = await response.text();
+ const $ = cheerio.load(content);
+
+ const og: { [key: string]: string | undefined } = {};
+ const twitter: { [key: string]: string | undefined } = {};
+ // @ts-ignore trust
+ $("meta[property^=og:]").each(
+ (_, el) => (og[$(el).attr("property")!] = $(el).attr("content")),
+ );
+ // @ts-ignore trust
+ $("meta[name^=twitter:]").each(
+ (_, el) => (twitter[$(el).attr("name")!] = $(el).attr("content")),
+ );
+
+ const title =
+ og["og:title"] ??
+ twitter["twitter:title"] ??
+ $("title").text() ??
+ undefined;
+ const description =
+ og["og:description"] ??
+ twitter["twitter:description"] ??
+ $('meta[name="description"]').attr("content") ??
+ undefined;
+ const image =
+ og["og:image:secure_url"] ??
+ og["og:image"] ??
+ twitter["twitter:image"] ??
+ undefined;
+ const favicon =
+ $('link[rel="apple-touch-icon"]').attr("href") ??
+ $('link[rel="icon"]').attr("href") ??
+ undefined;
+
+ return {
+ title,
+ description,
+ image,
+ favicon,
+ };
+}