aboutsummaryrefslogtreecommitdiff
path: root/apps/web/lib
diff options
context:
space:
mode:
authorcodetorso <[email protected]>2024-07-25 10:56:32 +0530
committercodetorso <[email protected]>2024-07-25 10:56:32 +0530
commitc7b98a39b8024c96f1230a513a6d64d763b74277 (patch)
tree0bc3260cfce7cbf5666948f21864806b6d2a9760 /apps/web/lib
parentparse suggestions for handling edge case (diff)
downloadsupermemory-c7b98a39b8024c96f1230a513a6d64d763b74277.tar.xz
supermemory-c7b98a39b8024c96f1230a513a6d64d763b74277.zip
let's go boys!! canvas
Diffstat (limited to 'apps/web/lib')
-rw-r--r--apps/web/lib/ExternalDroppedContent.ts284
-rw-r--r--apps/web/lib/createEmbeds.ts236
-rw-r--r--apps/web/lib/loadSnap.ts5
-rw-r--r--apps/web/lib/unfirlsite.ts41
4 files changed, 328 insertions, 238 deletions
diff --git a/apps/web/lib/ExternalDroppedContent.ts b/apps/web/lib/ExternalDroppedContent.ts
new file mode 100644
index 00000000..75ca8b6f
--- /dev/null
+++ b/apps/web/lib/ExternalDroppedContent.ts
@@ -0,0 +1,284 @@
+import {
+ AssetRecordType,
+ Editor,
+ TLAsset,
+ TLAssetId,
+ TLBookmarkShape,
+ TLExternalContentSource,
+ TLShapePartial,
+ Vec,
+ VecLike,
+ createShapeId,
+ getEmbedInfo,
+ getHashForString,
+} from "tldraw";
+import { unfirlSite } from "@/app/actions/fetchers";
+
+export default async function createEmbedsFromUrl({
+ url,
+ point,
+ sources,
+ editor,
+}: {
+ url: string;
+ point?: VecLike | undefined;
+ sources?: TLExternalContentSource[] | undefined;
+ editor: Editor;
+}) {
+ const position =
+ point ??
+ (editor.inputs.shiftKey
+ ? editor.inputs.currentPagePoint
+ : editor.getViewportPageBounds().center);
+
+ const urlPattern = /https?:\/\/(x\.com|twitter\.com)\/[\w]+\/[\w]+\/[\d]+/;
+ if (urlPattern.test(url)) {
+ return editor.createShape({
+ type: "Twittercard",
+ x: position.x - 250,
+ y: position.y - 150,
+ props: { url: url },
+ });
+ }
+
+ // try to paste as an embed first
+ const embedInfo = getEmbedInfo(url);
+
+ if (embedInfo) {
+ return editor.putExternalContent({
+ type: "embed",
+ url: embedInfo.url,
+ point,
+ embed: embedInfo.definition,
+ });
+ }
+
+ const assetId: TLAssetId = AssetRecordType.createId(getHashForString(url));
+ const shape = createEmptyBookmarkShape(editor, url, position);
+
+ let asset = editor.getAsset(assetId) as TLAsset;
+ let shouldAlsoCreateAsset = false;
+ if (!asset) {
+ shouldAlsoCreateAsset = true;
+ try {
+ const bookmarkAsset = await editor.getAssetForExternalContent({
+ type: "url",
+ url,
+ });
+ const value = await unfirlSite(url);
+ if (bookmarkAsset) {
+ 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;
+ } catch (e) {
+ console.log(e);
+ return;
+ }
+ }
+
+ editor.batch(() => {
+ if (shouldAlsoCreateAsset) {
+ editor.createAssets([asset]);
+ }
+
+ editor.updateShapes([
+ {
+ id: shape.id,
+ type: shape.type,
+ props: {
+ assetId: asset.id,
+ },
+ },
+ ]);
+ });
+}
+
+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 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 = {
+ type: string;
+ title: string;
+ content: string;
+ url: string;
+};
+
+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 });
+ return;
+ } else {
+ const { height, width } = formatTextToRatio(droppedData);
+ editor.createShape({
+ type: "Textcard",
+ x: position.x - width / 2,
+ y: position.y - height / 2,
+ props: {
+ content: "",
+ extrainfo: droppedData,
+ type: "note",
+ w: 300,
+ h: 200,
+ },
+ });
+ }
+ } else if ("imageUrl" in droppedData) {
+ } else {
+ const { content, title, url, type } = droppedData;
+ const processedURL = processURL(url);
+ if (processedURL) {
+ createEmbedsFromUrl({ editor, url: processedURL });
+ return;
+ }
+ const { height, width } = formatTextToRatio(content);
+
+ editor.createShape({
+ type: "Textcard",
+ x: position.x - 250,
+ y: position.y - 150,
+ props: {
+ type,
+ content: title,
+ extrainfo: content,
+ w: height,
+ h: width,
+ },
+ });
+ }
+}
+
+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/createEmbeds.ts b/apps/web/lib/createEmbeds.ts
deleted file mode 100644
index 75347d31..00000000
--- a/apps/web/lib/createEmbeds.ts
+++ /dev/null
@@ -1,236 +0,0 @@
-// @ts-nocheck TODO: A LOT OF TS ERRORS HERE
-
-import {
- AssetRecordType,
- Editor,
- TLAsset,
- TLAssetId,
- TLBookmarkShape,
- TLExternalContentSource,
- TLShapePartial,
- Vec,
- VecLike,
- createShapeId,
- getEmbedInfo,
- getHashForString,
-} from "tldraw";
-
-export default async function createEmbedsFromUrl({
- url,
- point,
- sources,
- editor,
-}: {
- url: string;
- point?: VecLike | undefined;
- sources?: TLExternalContentSource[] | undefined;
- editor: Editor;
-}) {
- const position =
- point ??
- (editor.inputs.shiftKey
- ? editor.inputs.currentPagePoint
- : editor.getViewportPageBounds().center);
-
- if (url?.includes("x.com") || url?.includes("twitter.com")) {
- return editor.createShape({
- type: "Twittercard",
- x: position.x - 250,
- y: position.y - 150,
- props: { url: url },
- });
- }
-
- // try to paste as an embed first
- const embedInfo = getEmbedInfo(url);
-
- if (embedInfo) {
- return editor.putExternalContent({
- type: "embed",
- url: embedInfo.url,
- point,
- embed: embedInfo.definition,
- });
- }
-
- 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) {
- shouldAlsoCreateAsset = true;
- try {
- const bookmarkAsset = await editor.getAssetForExternalContent({
- type: "url",
- url,
- });
- const fetchWebsite: {
- title?: string;
- image?: string;
- description?: string;
- } = await (
- await fetch(`/api/unfirlsite?website=${url}`, {
- method: "POST",
- })
- ).json();
- 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) throw Error("Could not create an asset");
- asset = bookmarkAsset;
- } catch (e) {
- console.log(e);
- return;
- }
- }
-
- editor.batch(() => {
- if (shouldAlsoCreateAsset) {
- editor.createAssets([asset]);
- }
-
- editor.updateShapes([
- {
- id: shape.id,
- type: shape.type,
- props: {
- assetId: asset.id,
- },
- },
- ]);
- });
-}
-
-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/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..678b26c8
--- /dev/null
+++ b/apps/web/lib/unfirlsite.ts
@@ -0,0 +1,41 @@
+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,
+ }
+} \ No newline at end of file