mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-02 21:50:10 +00:00
let's go boys!! canvas
This commit is contained in:
parent
3c22a45df3
commit
c7b98a39b8
240 changed files with 1212 additions and 12199 deletions
284
apps/web/lib/ExternalDroppedContent.ts
Normal file
284
apps/web/lib/ExternalDroppedContent.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
41
apps/web/lib/unfirlsite.ts
Normal file
41
apps/web/lib/unfirlsite.ts
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue