diff options
| author | codetorso <[email protected]> | 2024-07-25 10:56:32 +0530 |
|---|---|---|
| committer | codetorso <[email protected]> | 2024-07-25 10:56:32 +0530 |
| commit | c7b98a39b8024c96f1230a513a6d64d763b74277 (patch) | |
| tree | 0bc3260cfce7cbf5666948f21864806b6d2a9760 /apps/web/lib | |
| parent | parse suggestions for handling edge case (diff) | |
| download | supermemory-c7b98a39b8024c96f1230a513a6d64d763b74277.tar.xz supermemory-c7b98a39b8024c96f1230a513a6d64d763b74277.zip | |
let's go boys!! canvas
Diffstat (limited to 'apps/web/lib')
| -rw-r--r-- | apps/web/lib/ExternalDroppedContent.ts | 284 | ||||
| -rw-r--r-- | apps/web/lib/createEmbeds.ts | 236 | ||||
| -rw-r--r-- | apps/web/lib/loadSnap.ts | 5 | ||||
| -rw-r--r-- | apps/web/lib/unfirlsite.ts | 41 |
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 |