let's go boys!! canvas

This commit is contained in:
codetorso 2024-07-25 10:56:32 +05:30
parent 3c22a45df3
commit c7b98a39b8
240 changed files with 1212 additions and 12199 deletions

View 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;
}

View file

@ -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;
}

View file

@ -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);

View 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,
}
}