From 3144b5e4376acdef9fa10d79e8713b2438de55f2 Mon Sep 17 00:00:00 2001 From: Chris Griffing Date: Tue, 22 Oct 2024 18:46:44 -0700 Subject: [PATCH 1/3] feat: extract editor to component --- .../app/migrations/0001_burly_alice.sql | 12 + .../app/migrations/meta/0001_snapshot.json | 271 +++++++ playgrounds/app/migrations/meta/_journal.json | 7 + playgrounds/app/src/components/Editor.tsx | 754 ++++++++++++++++++ playgrounds/app/src/db/schema.ts | 14 +- playgrounds/app/src/lib/validators.ts | 11 + playgrounds/app/src/routes/api/snippets.ts | 28 +- .../src/routes/api/snippets/[snippetId].ts | 44 +- playgrounds/app/src/routes/index.tsx | 743 +---------------- .../app/src/routes/snippets/[snippetId].tsx | 34 +- playgrounds/app/src/types.ts | 37 + 11 files changed, 1232 insertions(+), 723 deletions(-) create mode 100644 playgrounds/app/migrations/0001_burly_alice.sql create mode 100644 playgrounds/app/migrations/meta/0001_snapshot.json create mode 100644 playgrounds/app/src/components/Editor.tsx diff --git a/playgrounds/app/migrations/0001_burly_alice.sql b/playgrounds/app/migrations/0001_burly_alice.sql new file mode 100644 index 0000000..6724505 --- /dev/null +++ b/playgrounds/app/migrations/0001_burly_alice.sql @@ -0,0 +1,12 @@ +ALTER TABLE `snippets_table` ADD `snippetWidth` integer DEFAULT 450 NOT NULL;--> statement-breakpoint +ALTER TABLE `snippets_table` ADD `yPadding` integer DEFAULT 42 NOT NULL;--> statement-breakpoint +ALTER TABLE `snippets_table` ADD `xPadding` integer DEFAULT 42 NOT NULL;--> statement-breakpoint +ALTER TABLE `snippets_table` ADD `shadowEnabled` integer DEFAULT 1 NOT NULL;--> statement-breakpoint +ALTER TABLE `snippets_table` ADD `shadowOffsetY` integer DEFAULT 10 NOT NULL;--> statement-breakpoint +ALTER TABLE `snippets_table` ADD `shadowBlur` integer DEFAULT 10 NOT NULL;--> statement-breakpoint +ALTER TABLE `snippets_table` ADD `shadowColor` text DEFAULT '#000000' NOT NULL;--> statement-breakpoint +ALTER TABLE `snippets_table` ADD `shadowOpacity` real DEFAULT 0.6 NOT NULL;--> statement-breakpoint +ALTER TABLE `snippets_table` ADD `bgColor` text DEFAULT '#ffffff' NOT NULL;--> statement-breakpoint +ALTER TABLE `snippets_table` ADD `language` text DEFAULT 'tsx' NOT NULL;--> statement-breakpoint +ALTER TABLE `snippets_table` ADD `theme` text DEFAULT 'nord' NOT NULL;--> statement-breakpoint +ALTER TABLE `snippets_table` ALTER COLUMN "userId" TO "userId" text NOT NULL REFERENCES users_table(id) ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/playgrounds/app/migrations/meta/0001_snapshot.json b/playgrounds/app/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..6f42f10 --- /dev/null +++ b/playgrounds/app/migrations/meta/0001_snapshot.json @@ -0,0 +1,271 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a25d9e7c-46fc-4f89-b78f-a5382632f50d", + "prevId": "05492c90-59bd-45e4-99e5-29a29eb2d4ef", + "tables": { + "snippets_table": { + "name": "snippets_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeLeft": { + "name": "codeLeft", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeRight": { + "name": "codeRight", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "snippetWidth": { + "name": "snippetWidth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 450 + }, + "yPadding": { + "name": "yPadding", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 42 + }, + "xPadding": { + "name": "xPadding", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 42 + }, + "shadowEnabled": { + "name": "shadowEnabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "shadowOffsetY": { + "name": "shadowOffsetY", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + }, + "shadowBlur": { + "name": "shadowBlur", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + }, + "shadowColor": { + "name": "shadowColor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#000000'" + }, + "shadowOpacity": { + "name": "shadowOpacity", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0.6 + }, + "bgColor": { + "name": "bgColor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#ffffff'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'tsx'" + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'nord'" + } + }, + "indexes": {}, + "foreignKeys": { + "snippets_table_userId_users_table_id_fk": { + "name": "snippets_table_userId_users_table_id_fk", + "tableFrom": "snippets_table", + "tableTo": "users_table", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_table": { + "name": "users_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "githubId": { + "name": "githubId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "githubUsername": { + "name": "githubUsername", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "githubAvatarUrl": { + "name": "githubAvatarUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updatedAt": { + "name": "updatedAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "users_table_email_unique": { + "name": "users_table_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_table_githubId_unique": { + "name": "users_table_githubId_unique", + "columns": [ + "githubId" + ], + "isUnique": true + }, + "users_table_githubUsername_unique": { + "name": "users_table_githubUsername_unique", + "columns": [ + "githubUsername" + ], + "isUnique": true + }, + "users_table_githubAvatarUrl_unique": { + "name": "users_table_githubAvatarUrl_unique", + "columns": [ + "githubAvatarUrl" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/playgrounds/app/migrations/meta/_journal.json b/playgrounds/app/migrations/meta/_journal.json index c47ec85..ccb1938 100644 --- a/playgrounds/app/migrations/meta/_journal.json +++ b/playgrounds/app/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1729551859421, "tag": "0000_last_patch", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1729646676735, + "tag": "0001_burly_alice", + "breakpoints": true } ] } \ No newline at end of file diff --git a/playgrounds/app/src/components/Editor.tsx b/playgrounds/app/src/components/Editor.tsx new file mode 100644 index 0000000..b97682a --- /dev/null +++ b/playgrounds/app/src/components/Editor.tsx @@ -0,0 +1,754 @@ +import { interpolate, interpolateColors, Easing } from 'remotion' +import { encode } from 'modern-gif' +import workerUrl from 'modern-gif/worker?url' +import 'shiki-magic-move/dist/style.css' +import { + ComboboxItem, + ComboboxItemLabel, + ComboboxItemIndicator, + ComboboxControl, + ComboboxInput, + ComboboxTrigger, + ComboboxContent, + Combobox, +} from '~/components/ui/combobox' +import { Button } from '~/components/ui/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs' +import { TextField, TextFieldLabel, TextFieldTextArea } from '~/components/ui/text-field' +import { MagicMoveElement } from 'shiki-magic-move/types' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '~/components/ui/dropdown-menu' +import { + Slider, + SliderFill, + SliderLabel, + SliderThumb, + SliderTrack, + SliderValueLabel, +} from '~/components/ui/slider' +import clsx from 'clsx' +import { TbSettings } from 'solid-icons/tb' +import { Checkbox } from '~/components/ui/checkbox' +import { Label } from '~/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '~/components/ui/dialog' +import { createMemo, createResource, createSignal, onCleanup, Setter, Show } from 'solid-js' +import { createHighlighter, bundledThemes, bundledLanguages } from 'shiki' +import { ShikiMagicMove } from 'shiki-magic-move/solid' +import { AnimationFrameConfig } from '~/types' + +const animationSeconds = 1 +const animationFPS = 10 +const animationFrames = animationSeconds * animationFPS + +interface EditorProps { + startCode: string + setStartCode: Setter + endCode: string + setEndCode: Setter + snippetWidth: number + setSnippetWidth: Setter + yPadding: number + setYPadding: Setter + xPadding: number + setXPadding: Setter + shadowEnabled: boolean + setShadowEnabled: Setter + shadowOffsetY: number + setShadowOffsetY: Setter + shadowBlur: number + setShadowBlur: Setter + shadowColor: string + setShadowColor: Setter + shadowOpacity: number + setShadowOpacity: Setter + bgColor: string + setBgColor: Setter + language: string + setLanguage: Setter + theme: string + setTheme: Setter +} + +export default function Editor(props: EditorProps) { + const { + startCode, + setStartCode, + endCode, + setEndCode, + snippetWidth, + setSnippetWidth, + yPadding, + setYPadding, + xPadding, + setXPadding, + shadowEnabled, + setShadowEnabled, + shadowOffsetY, + setShadowOffsetY, + shadowBlur, + setShadowBlur, + shadowColor, + setShadowColor, + shadowOpacity, + setShadowOpacity, + bgColor, + setBgColor, + language, + setLanguage, + theme, + setTheme, + } = props + + const [selectedTab, setSelectedTab] = createSignal<'snippets' | 'output'>('snippets') + const [toggled, setToggled] = createSignal(false) + + const [magicMoveElements, setMagicMoveElements] = createSignal([]) + const [maxContainerDimensions, setMaxContainerDimensions] = createSignal<{ + width: number + height: number + }>() + const [code, setCode] = createSignal(startCode) + const [isResizing, setIsResizing] = createSignal(false) + const [isLooping, setIsLooping] = createSignal(true) + const [isGenerating, setIsGenerating] = createSignal(false) + const [isGenerated, setIsGenerated] = createSignal(false) + const [gifDataUrl, setGifDataUrl] = createSignal('') + const [isShowingGifDialog, setIsShowingGifDialog] = createSignal(false) + + const [highlighter] = createResource(async () => { + const newHighlighter = await createHighlighter({ + themes: Object.keys(bundledThemes), + langs: Object.keys(bundledLanguages), + }) + + return newHighlighter + }) + + const intervalId = setInterval(() => { + if ( + selectedTab() === 'output' && + startCode !== '' && + endCode !== '' && + !isResizing() && + isLooping() + ) { + if (toggled()) { + setCode(startCode) + } else { + setCode(endCode) + } + setToggled(!toggled()) + } + }, 3000) + + onCleanup(() => { + clearInterval(intervalId) + }) + + document.body.addEventListener('mousemove', e => { + if (isResizing()) { + const deltaX = e.movementX + // console.log(e.) + setSnippetWidth(snippetWidth + deltaX) + } + }) + + document.body.addEventListener('mouseup', e => { + if (isResizing()) { + setIsResizing(false) + } + }) + + const generateGifDataUrl = createMemo(() => { + return async function () { + const container = document.querySelector('.shiki-magic-move-container') as HTMLPreElement + + const canvasFrames: ImageData[] = [] + const backgroundColor = container.style.backgroundColor + + let fontSize = '' + let fontFamily = '' + + magicMoveElements().some(el => { + const computedStyle = window.getComputedStyle(el.el) + fontSize = computedStyle.getPropertyValue('font-size') + fontFamily = computedStyle.getPropertyValue('font-family') + + return fontSize && fontFamily + }) + + const loopedFrames = [] + const middleFrames = [] + + for (let i = 0; i < animationFrames; i++) { + middleFrames.push(i) + } + + const pauseFrameLength = 15 + const firstFrames = new Array(pauseFrameLength).fill(0) + const lastFrames = new Array(pauseFrameLength).fill(animationFrames) + + loopedFrames.push( + ...firstFrames, + ...middleFrames, + ...lastFrames, + ...middleFrames.toReversed(), + ) + + for (let frame = 0; frame < loopedFrames.length; frame++) { + const actualFrame = loopedFrames[frame] + + const canvas = await createAnimationFrame( + magicMoveElements(), + actualFrame, + maxContainerDimensions()?.width || 100, + maxContainerDimensions()?.height || 100, + { + layout: { + yPadding: yPadding, + xPadding: xPadding, + }, + shadow: { + shadowEnabled: shadowEnabled, + shadowOffsetY: shadowOffsetY, + shadowBlur: shadowBlur, + shadowColor: shadowColor, + shadowOpacity: shadowOpacity, + }, + styling: { + fontSize, + fontFamily, + snippetBackgroundColor: backgroundColor, + backgroundColor: bgColor, + }, + }, + ) + + canvasFrames.push(canvas) + } + + const blob = await encode({ + workerUrl, + format: 'blob', + width: canvasFrames[0].width, + height: canvasFrames[0].height, + frames: canvasFrames, + }) + + const dataUrl = await blobToDataURL(blob) + + return dataUrl?.toString() || '' + } + }) + + return ( + <> + + + Step 1: Snippets + Step 2: Output + + +
+
+
Enter the code snippets you would like to diff
+
+
+ +
+
+ +
+ + Start Code + + + + + End Code + + +
+
+ +
+
+ ( + + {props.item.rawValue} + + + )} + > + + + + + + + + ( + + {props.item.rawValue} + + + )} + > + + + + + + + + { + setIsLooping(!open) + }} + > + + + + + + + + setBgColor(e.target.value)} + /> + + + Layout + + + + { + setYPadding(e[0]) + }} + > +
+ Padding (y) + +
+ + + + +
+
+ + + { + setXPadding(e[0]) + }} + > +
+ Padding (x) + +
+ + + + +
+
+
+
+
+ + + Shadow + + + { + setShadowEnabled(!shadowEnabled) + }} + > + + + + + + + + setShadowColor(e.target.value)} + /> + + + { + setShadowOpacity(e[0]) + }} + > +
+ Opacity + +
+ + + + +
+
+ + { + setShadowOffsetY(e[0]) + }} + > +
+ Offset Y + +
+ + + + +
+
+ + { + setShadowBlur(e[0]) + }} + > +
+ Blur + +
+ + + + +
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+

Preview

+
+
+
+ + {highlighter => ( + <> +
+ { + if (elements.length === 0) { + return + } + + setMagicMoveElements(elements) + setMaxContainerDimensions(maxContainerDimensions) + }, + }} + /> +
+
{ + setIsResizing(true) + }} + >
+ + )} +
+
+
+
+
+
+
+ + + + +
+

Result

+
+
+ + Copying the image via right click will only copy the current frame. Please download + the GIF below by using the Download button or right clicking and using "Save Image + as...". + +
+ Generated gif + + + +
+
+ + ) +} + +function dataURItoBlob(dataURI: string) { + // convert base64 to raw binary data held in a string + // doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this + var byteString = atob(dataURI.split(',')[1]) + + // separate out the mime component + var mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0] + + // write the bytes of the string to an ArrayBuffer + var ab = new ArrayBuffer(byteString.length) + + // create a view into the buffer + var ia = new Uint8Array(ab) + + // set the bytes of the buffer to the correct values + for (var i = 0; i < byteString.length; i++) { + ia[i] = byteString.charCodeAt(i) + } + + // write the ArrayBuffer to a blob, and you're done + var blob = new Blob([ab], { type: mimeString }) + return blob +} + +function blobToDataURL(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = function (e) { + resolve(e.target?.result) + } + reader.onerror = reject + reader.readAsDataURL(blob) + }) +} + +function htmlDecode(str: string) { + const txt = document.createElement('textarea') + txt.innerHTML = str + return txt.value +} + +const snippetPadding = 16 + +async function createAnimationFrame( + elements: MagicMoveElement[], + frame: number, + width: number = 100, + height: number = 100, + config: AnimationFrameConfig, +) { + const { yPadding, xPadding } = config.layout + const { shadowEnabled, shadowOffsetY, shadowBlur, shadowColor, shadowOpacity } = config.shadow + const { fontSize, fontFamily, backgroundColor, snippetBackgroundColor } = config.styling + + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d', { alpha: false }) + canvas.width = width + xPadding * 2 + canvas.height = height + yPadding * 2 + ctx!.fillStyle = backgroundColor + ctx?.fillRect(0, 0, canvas.width, canvas.height) + + ctx!.fillStyle = snippetBackgroundColor + if (shadowEnabled) { + ctx!.shadowColor = `${shadowColor}${(shadowOpacity * 255).toString(16)}` + ctx!.shadowBlur = shadowBlur + ctx!.shadowOffsetX = 0 + ctx!.shadowOffsetY = shadowOffsetY + } + + ctx!.beginPath() + ctx!.roundRect(xPadding, yPadding, width, height, 4) + ctx!.fill() + + ctx!.shadowColor = 'transparent' + + const xModifier = xPadding + const yModifier = yPadding + snippetPadding + + const elementPromises = elements.map(async el => { + const x = interpolate( + frame, + [0, animationFrames], + [el.x.start + xModifier, el.x.end + xModifier], + { + easing: Easing.inOut(Easing.quad), + }, + ) + const y = interpolate( + frame, + [0, animationFrames], + [el.y.start + yModifier, el.y.end + yModifier], + { + easing: Easing.inOut(Easing.quad), + }, + ) + const opacity = interpolate(frame, [0, animationFrames], [el.opacity.start, el.opacity.end], { + easing: Easing.inOut(Easing.quad), + }) + const color = interpolateColors( + frame, + [0, animationFrames], + [el.color.start || 'rgba(0,0,0,0)', el.color.end || 'rgba(0,0,0,0)'], + ) + + ctx!.font = `${fontSize} ${fontFamily}` + ctx!.fillStyle = color + ctx!.globalAlpha = opacity + ctx!.fillText(htmlDecode(el.el.innerHTML), x, y) + }) + await Promise.all(elementPromises) + + return ctx!.getImageData(0, 0, canvas.width, canvas.height) +} diff --git a/playgrounds/app/src/db/schema.ts b/playgrounds/app/src/db/schema.ts index 58d477f..9f89879 100644 --- a/playgrounds/app/src/db/schema.ts +++ b/playgrounds/app/src/db/schema.ts @@ -1,4 +1,4 @@ -import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core' +import { int, sqliteTable, text, real } from 'drizzle-orm/sqlite-core' export const usersTable = sqliteTable('users_table', { id: text().primaryKey(), @@ -20,6 +20,18 @@ export const snippetsTable = sqliteTable('snippets_table', { codeRight: text().notNull(), createdAt: int().notNull().default(0), updatedAt: int().notNull().default(0), + snippetWidth: int().notNull().default(450), + yPadding: int().notNull().default(42), + xPadding: int().notNull().default(42), + // sqlite doesn't support booleans + shadowEnabled: int().notNull().default(1), + shadowOffsetY: int().notNull().default(10), + shadowBlur: int().notNull().default(10), + shadowColor: text().notNull().default('#000000'), + shadowOpacity: real().notNull().default(0.6), + bgColor: text().notNull().default('#ffffff'), + language: text().notNull().default('tsx'), + theme: text().notNull().default('nord'), }) export const schema = { diff --git a/playgrounds/app/src/lib/validators.ts b/playgrounds/app/src/lib/validators.ts index 4e1ddf6..fb5ad6c 100644 --- a/playgrounds/app/src/lib/validators.ts +++ b/playgrounds/app/src/lib/validators.ts @@ -10,4 +10,15 @@ export const snippetValidator = z.object({ .string() .min(1) .max(5 * 1024), + snippetWidth: z.number().min(1).max(5000), + yPadding: z.number().min(0).max(200), + xPadding: z.number().min(0).max(200), + shadowEnabled: z.boolean(), + shadowOffsetY: z.number().min(0).max(200), + shadowBlur: z.number().min(1).max(1000), + shadowColor: z.string().min(1).max(30), + shadowOpacity: z.number().min(0).max(1), + bgColor: z.string().min(1).max(30), + language: z.string().min(1).max(1000), + theme: z.string().min(1).max(1000), }) diff --git a/playgrounds/app/src/routes/api/snippets.ts b/playgrounds/app/src/routes/api/snippets.ts index 821a083..87817a7 100644 --- a/playgrounds/app/src/routes/api/snippets.ts +++ b/playgrounds/app/src/routes/api/snippets.ts @@ -34,7 +34,22 @@ export async function POST(event: APIEvent) { }) } - const { title, codeLeft, codeRight } = await event.request.json() + const { + title, + codeLeft, + codeRight, + snippetWidth, + yPadding, + xPadding, + shadowEnabled, + shadowOffsetY, + shadowBlur, + shadowColor, + shadowOpacity, + bgColor, + language, + theme, + } = await event.request.json() const isValid = snippetValidator.safeParse({ title, codeLeft, codeRight }) @@ -55,6 +70,17 @@ export async function POST(event: APIEvent) { codeRight, createdAt: Date.now(), updatedAt: Date.now(), + snippetWidth, + yPadding, + xPadding, + shadowEnabled: shadowEnabled ? 1 : 0, + shadowOffsetY, + shadowBlur, + shadowColor, + shadowOpacity, + bgColor, + language, + theme, } await db.insert(snippetsTable).values(newSnippet) diff --git a/playgrounds/app/src/routes/api/snippets/[snippetId].ts b/playgrounds/app/src/routes/api/snippets/[snippetId].ts index efb1fcb..200004b 100644 --- a/playgrounds/app/src/routes/api/snippets/[snippetId].ts +++ b/playgrounds/app/src/routes/api/snippets/[snippetId].ts @@ -30,12 +30,18 @@ export async function GET(event: APIEvent) { }) } - return new Response(JSON.stringify(snippet), { - status: 200, - headers: { - 'Content-Type': 'application/json', + return new Response( + JSON.stringify({ + ...snippet, + shadowEnabled: snippet.shadowEnabled === 1, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, }, - }) + ) } export async function PUT(event: APIEvent) { @@ -70,7 +76,22 @@ export async function PUT(event: APIEvent) { }) } - const { title, codeLeft, codeRight } = await event.request.json() + const { + title, + codeLeft, + codeRight, + snippetWidth, + yPadding, + xPadding, + shadowEnabled, + shadowOffsetY, + shadowBlur, + shadowColor, + shadowOpacity, + bgColor, + language, + theme, + } = await event.request.json() const isValid = snippetValidator.safeParse({ title, codeLeft, codeRight }) @@ -91,6 +112,17 @@ export async function PUT(event: APIEvent) { codeLeft, codeRight, updatedAt: Date.now(), + snippetWidth, + yPadding, + xPadding, + shadowEnabled: shadowEnabled ? 1 : 0, + shadowOffsetY, + shadowBlur, + shadowColor, + shadowOpacity, + bgColor, + language, + theme, }) .where(eq(snippetsTable.id, snippetId)) diff --git a/playgrounds/app/src/routes/index.tsx b/playgrounds/app/src/routes/index.tsx index ca6a61d..99f805a 100644 --- a/playgrounds/app/src/routes/index.tsx +++ b/playgrounds/app/src/routes/index.tsx @@ -1,59 +1,6 @@ -import { createMemo, createResource, createSignal, onCleanup, Show } from 'solid-js' -import { createHighlighter, bundledThemes, bundledLanguages } from 'shiki' -import { ShikiMagicMove } from 'shiki-magic-move/solid' +import { createSignal } from 'solid-js' import { makePersisted } from '@solid-primitives/storage' -import { interpolate, interpolateColors, Easing } from 'remotion' -import { encode } from 'modern-gif' -import workerUrl from 'modern-gif/worker?url' -import 'shiki-magic-move/dist/style.css' -import { - ComboboxItem, - ComboboxItemLabel, - ComboboxItemIndicator, - ComboboxControl, - ComboboxInput, - ComboboxTrigger, - ComboboxContent, - Combobox, -} from '~/components/ui/combobox' -import { Button } from '~/components/ui/button' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs' -import { TextField, TextFieldLabel, TextFieldTextArea } from '~/components/ui/text-field' -import { MagicMoveElement } from 'shiki-magic-move/types' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from '~/components/ui/dropdown-menu' -import { - Slider, - SliderFill, - SliderLabel, - SliderThumb, - SliderTrack, - SliderValueLabel, -} from '~/components/ui/slider' -import clsx from 'clsx' -import { TbSettings } from 'solid-icons/tb' -import { Checkbox } from '~/components/ui/checkbox' -import { Label } from '~/components/ui/label' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '~/components/ui/dialog' - -const animationSeconds = 1 -const animationFPS = 10 -const animationFrames = animationSeconds * animationFPS +import Editor from '~/components/Editor' const left = ` import { render } from "solid-js/web"; @@ -79,8 +26,6 @@ render(() => , document.getElementById('app')); ` export default function Home() { - const [selectedTab, setSelectedTab] = createSignal<'snippets' | 'output'>('snippets') - const [toggled, setToggled] = createSignal(false) const [theme, setTheme] = makePersisted(createSignal('nord'), { name: 'theme' }) const [language, setLanguage] = makePersisted(createSignal('tsx'), { name: 'language', @@ -119,667 +64,39 @@ export default function Home() { name: 'snippetWidth', }) - const [magicMoveElements, setMagicMoveElements] = createSignal([]) - const [maxContainerDimensions, setMaxContainerDimensions] = createSignal<{ - width: number - height: number - }>() - const [code, setCode] = createSignal(startCode()) - const [isResizing, setIsResizing] = createSignal(false) - const [isLooping, setIsLooping] = createSignal(true) - const [isGenerating, setIsGenerating] = createSignal(false) - const [isGenerated, setIsGenerated] = createSignal(false) - const [gifDataUrl, setGifDataUrl] = createSignal('') - const [isShowingGifDialog, setIsShowingGifDialog] = createSignal(false) - - const [highlighter] = createResource(async () => { - const newHighlighter = await createHighlighter({ - themes: Object.keys(bundledThemes), - langs: Object.keys(bundledLanguages), - }) - - return newHighlighter - }) - - const intervalId = setInterval(() => { - if ( - selectedTab() === 'output' && - startCode() !== '' && - endCode() !== '' && - !isResizing() && - isLooping() - ) { - if (toggled()) { - setCode(startCode()) - } else { - setCode(endCode()) - } - setToggled(!toggled()) - } - }, 3000) - - onCleanup(() => { - clearInterval(intervalId) - }) - - document.body.addEventListener('mousemove', e => { - if (isResizing()) { - const deltaX = e.movementX - // console.log(e.) - setSnippetWidth(snippetWidth() + deltaX) - } - }) - - document.body.addEventListener('mouseup', e => { - if (isResizing()) { - setIsResizing(false) - } - }) - - const generateGifDataUrl = createMemo(() => { - return async function () { - const container = document.querySelector('.shiki-magic-move-container') as HTMLPreElement - - const canvasFrames: ImageData[] = [] - const backgroundColor = container.style.backgroundColor - - let fontSize = '' - let fontFamily = '' - - magicMoveElements().some(el => { - const computedStyle = window.getComputedStyle(el.el) - fontSize = computedStyle.getPropertyValue('font-size') - fontFamily = computedStyle.getPropertyValue('font-family') - - return fontSize && fontFamily - }) - - const loopedFrames = [] - const middleFrames = [] - - for (let i = 0; i < animationFrames; i++) { - middleFrames.push(i) - } - - const pauseFrameLength = 15 - const firstFrames = new Array(pauseFrameLength).fill(0) - const lastFrames = new Array(pauseFrameLength).fill(animationFrames) - - loopedFrames.push( - ...firstFrames, - ...middleFrames, - ...lastFrames, - ...middleFrames.toReversed(), - ) - - for (let frame = 0; frame < loopedFrames.length; frame++) { - const actualFrame = loopedFrames[frame] - - const canvas = await createAnimationFrame( - magicMoveElements(), - actualFrame, - maxContainerDimensions()?.width || 100, - maxContainerDimensions()?.height || 100, - { - layout: { - yPadding: yPadding(), - xPadding: xPadding(), - }, - shadow: { - shadowEnabled: shadowEnabled(), - shadowOffsetY: shadowOffsetY(), - shadowBlur: shadowBlur(), - shadowColor: shadowColor(), - shadowOpacity: shadowOpacity(), - }, - styling: { - fontSize, - fontFamily, - snippetBackgroundColor: backgroundColor, - backgroundColor: bgColor(), - }, - }, - ) - - canvasFrames.push(canvas) - } - - const blob = await encode({ - workerUrl, - format: 'blob', - width: canvasFrames[0].width, - height: canvasFrames[0].height, - frames: canvasFrames, - }) - - const dataUrl = await blobToDataURL(blob) - - return dataUrl?.toString() || '' - } - }) - return (

Create and share beautiful gifs of your source code diffs.

- - - Step 1: Snippets - Step 2: Output - - -
-
-
Enter the code snippets you would like to diff
-
-
- -
-
- -
- - Start Code - - - - - End Code - - -
-
- -
-
- ( - - {props.item.rawValue} - - - )} - > - - - - - - - - ( - - {props.item.rawValue} - - - )} - > - - - - - - - - { - setIsLooping(!open) - }} - > - - - - - - - - setBgColor(e.target.value)} - /> - - - Layout - - - - { - setYPadding(e[0]) - }} - > -
- Padding (y) - -
- - - - -
-
- - - { - setXPadding(e[0]) - }} - > -
- Padding (x) - -
- - - - -
-
-
-
-
- - - Shadow - - - { - setShadowEnabled(!shadowEnabled()) - }} - > - - - - - - - - setShadowColor(e.target.value)} - /> - - - { - setShadowOpacity(e[0]) - }} - > -
- Opacity - -
- - - - -
-
- - { - setShadowOffsetY(e[0]) - }} - > -
- Offset Y - -
- - - - -
-
- - { - setShadowBlur(e[0]) - }} - > -
- Blur - -
- - - - -
-
-
-
-
-
-
-
-
-
- -
-
- -
-

Preview

-
-
-
- - {highlighter => ( - <> -
- { - if (elements.length === 0) { - return - } - - setMagicMoveElements(elements) - setMaxContainerDimensions(maxContainerDimensions) - }, - }} - /> -
-
{ - setIsResizing(true) - }} - >
- - )} -
-
-
-
-
-
-
- - - - -
-

Result

-
-
- - Copying the image via right click will only copy the current frame. Please download - the GIF below by using the Download button or right clicking and using "Save Image - as...". - -
- Generated gif - - - -
-
+
) } - -function dataURItoBlob(dataURI: string) { - // convert base64 to raw binary data held in a string - // doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this - var byteString = atob(dataURI.split(',')[1]) - - // separate out the mime component - var mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0] - - // write the bytes of the string to an ArrayBuffer - var ab = new ArrayBuffer(byteString.length) - - // create a view into the buffer - var ia = new Uint8Array(ab) - - // set the bytes of the buffer to the correct values - for (var i = 0; i < byteString.length; i++) { - ia[i] = byteString.charCodeAt(i) - } - - // write the ArrayBuffer to a blob, and you're done - var blob = new Blob([ab], { type: mimeString }) - return blob -} - -function blobToDataURL(blob: Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = function (e) { - resolve(e.target?.result) - } - reader.onerror = reject - reader.readAsDataURL(blob) - }) -} - -function htmlDecode(str: string) { - const txt = document.createElement('textarea') - txt.innerHTML = str - return txt.value -} - -interface AnimationFrameLayout { - yPadding: number - xPadding: number -} - -interface AnimationFrameShadow { - shadowEnabled: boolean - shadowOffsetY: number - shadowBlur: number - shadowColor: string - shadowOpacity: number -} - -interface AnimationFrameStyling { - fontSize: string - fontFamily: string - snippetBackgroundColor: string - backgroundColor: string -} - -interface AnimationFrameConfig { - layout: AnimationFrameLayout - shadow: AnimationFrameShadow - styling: AnimationFrameStyling -} - -const snippetPadding = 16 - -async function createAnimationFrame( - elements: MagicMoveElement[], - frame: number, - width: number = 100, - height: number = 100, - config: AnimationFrameConfig, -) { - const { yPadding, xPadding } = config.layout - const { shadowEnabled, shadowOffsetY, shadowBlur, shadowColor, shadowOpacity } = config.shadow - const { fontSize, fontFamily, backgroundColor, snippetBackgroundColor } = config.styling - - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d', { alpha: false }) - canvas.width = width + xPadding * 2 - canvas.height = height + yPadding * 2 - ctx!.fillStyle = backgroundColor - ctx?.fillRect(0, 0, canvas.width, canvas.height) - - ctx!.fillStyle = snippetBackgroundColor - if (shadowEnabled) { - ctx!.shadowColor = `${shadowColor}${(shadowOpacity * 255).toString(16)}` - ctx!.shadowBlur = shadowBlur - ctx!.shadowOffsetX = 0 - ctx!.shadowOffsetY = shadowOffsetY - } - - ctx!.beginPath() - ctx!.roundRect(xPadding, yPadding, width, height, 4) - ctx!.fill() - - ctx!.shadowColor = 'transparent' - - const xModifier = xPadding - const yModifier = yPadding + snippetPadding - - const elementPromises = elements.map(async el => { - const x = interpolate( - frame, - [0, animationFrames], - [el.x.start + xModifier, el.x.end + xModifier], - { - easing: Easing.inOut(Easing.quad), - }, - ) - const y = interpolate( - frame, - [0, animationFrames], - [el.y.start + yModifier, el.y.end + yModifier], - { - easing: Easing.inOut(Easing.quad), - }, - ) - const opacity = interpolate(frame, [0, animationFrames], [el.opacity.start, el.opacity.end], { - easing: Easing.inOut(Easing.quad), - }) - const color = interpolateColors( - frame, - [0, animationFrames], - [el.color.start || 'rgba(0,0,0,0)', el.color.end || 'rgba(0,0,0,0)'], - ) - - ctx!.font = `${fontSize} ${fontFamily}` - ctx!.fillStyle = color - ctx!.globalAlpha = opacity - ctx!.fillText(htmlDecode(el.el.innerHTML), x, y) - }) - await Promise.all(elementPromises) - - return ctx!.getImageData(0, 0, canvas.width, canvas.height) -} diff --git a/playgrounds/app/src/routes/snippets/[snippetId].tsx b/playgrounds/app/src/routes/snippets/[snippetId].tsx index 31c43cc..06a1341 100644 --- a/playgrounds/app/src/routes/snippets/[snippetId].tsx +++ b/playgrounds/app/src/routes/snippets/[snippetId].tsx @@ -1,4 +1,5 @@ -import { createResource } from 'solid-js' +import { createResource, Show } from 'solid-js' +import Editor from '~/components/Editor' import { authFetch } from '~/lib/utils' import { Snippet } from '~/types' @@ -14,7 +15,36 @@ export default function ViewSnippet({ params }: { params: { snippetId: string } return (
- Snippet ID: {snippet()?.id} + + {}} + endCode={snippet()!.codeRight} + setEndCode={() => {}} + snippetWidth={snippet()!.snippetWidth} + setSnippetWidth={() => {}} + yPadding={snippet()!.yPadding} + setYPadding={() => {}} + xPadding={snippet()!.xPadding} + setXPadding={() => {}} + shadowEnabled={snippet()!.shadowEnabled} + setShadowEnabled={() => {}} + shadowOffsetY={snippet()!.shadowOffsetY} + setShadowOffsetY={() => {}} + shadowBlur={snippet()!.shadowBlur} + setShadowBlur={() => {}} + shadowColor={snippet()!.shadowColor} + setShadowColor={() => {}} + shadowOpacity={snippet()!.shadowOpacity} + setShadowOpacity={() => {}} + bgColor={snippet()!.bgColor} + setBgColor={() => {}} + language={snippet()!.language} + setLanguage={() => {}} + theme={snippet()!.theme} + setTheme={() => {}} + /> +
) } diff --git a/playgrounds/app/src/types.ts b/playgrounds/app/src/types.ts index 792b13e..e936fbf 100644 --- a/playgrounds/app/src/types.ts +++ b/playgrounds/app/src/types.ts @@ -14,4 +14,41 @@ export interface Snippet { codeRight: string createdAt: number updatedAt: number + snippetWidth: number + yPadding: number + xPadding: number + shadowEnabled: boolean + shadowOffsetY: number + shadowBlur: number + shadowColor: string + shadowOpacity: number + bgColor: string + language: string + theme: string +} + +export interface AnimationFrameLayout { + yPadding: number + xPadding: number +} + +export interface AnimationFrameShadow { + shadowEnabled: boolean + shadowOffsetY: number + shadowBlur: number + shadowColor: string + shadowOpacity: number +} + +export interface AnimationFrameStyling { + fontSize: string + fontFamily: string + snippetBackgroundColor: string + backgroundColor: string +} + +export interface AnimationFrameConfig { + layout: AnimationFrameLayout + shadow: AnimationFrameShadow + styling: AnimationFrameStyling } From d8d848faa96a3f90e4c01c80e5d56c6558b57f5a Mon Sep 17 00:00:00 2001 From: Chris Griffing Date: Tue, 22 Oct 2024 19:24:52 -0700 Subject: [PATCH 2/3] fix: update snippet validator --- playgrounds/app/src/lib/validators.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/playgrounds/app/src/lib/validators.ts b/playgrounds/app/src/lib/validators.ts index fb5ad6c..cc8ca0c 100644 --- a/playgrounds/app/src/lib/validators.ts +++ b/playgrounds/app/src/lib/validators.ts @@ -15,10 +15,10 @@ export const snippetValidator = z.object({ xPadding: z.number().min(0).max(200), shadowEnabled: z.boolean(), shadowOffsetY: z.number().min(0).max(200), - shadowBlur: z.number().min(1).max(1000), + shadowBlur: z.number().min(1).max(200), shadowColor: z.string().min(1).max(30), shadowOpacity: z.number().min(0).max(1), bgColor: z.string().min(1).max(30), - language: z.string().min(1).max(1000), - theme: z.string().min(1).max(1000), + language: z.string().min(1).max(64), + theme: z.string().min(1).max(64), }) From 251fc794d4f2ca6914e9149e71e52939434d3ebb Mon Sep 17 00:00:00 2001 From: Chris Griffing Date: Tue, 22 Oct 2024 19:27:53 -0700 Subject: [PATCH 3/3] fix: update default bg color --- playgrounds/app/migrations/0001_burly_alice.sql | 2 +- playgrounds/app/src/routes/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/playgrounds/app/migrations/0001_burly_alice.sql b/playgrounds/app/migrations/0001_burly_alice.sql index 6724505..07cb2ef 100644 --- a/playgrounds/app/migrations/0001_burly_alice.sql +++ b/playgrounds/app/migrations/0001_burly_alice.sql @@ -6,7 +6,7 @@ ALTER TABLE `snippets_table` ADD `shadowOffsetY` integer DEFAULT 10 NOT NULL;--> ALTER TABLE `snippets_table` ADD `shadowBlur` integer DEFAULT 10 NOT NULL;--> statement-breakpoint ALTER TABLE `snippets_table` ADD `shadowColor` text DEFAULT '#000000' NOT NULL;--> statement-breakpoint ALTER TABLE `snippets_table` ADD `shadowOpacity` real DEFAULT 0.6 NOT NULL;--> statement-breakpoint -ALTER TABLE `snippets_table` ADD `bgColor` text DEFAULT '#ffffff' NOT NULL;--> statement-breakpoint +ALTER TABLE `snippets_table` ADD `bgColor` text DEFAULT '#a3d0ff' NOT NULL;--> statement-breakpoint ALTER TABLE `snippets_table` ADD `language` text DEFAULT 'tsx' NOT NULL;--> statement-breakpoint ALTER TABLE `snippets_table` ADD `theme` text DEFAULT 'nord' NOT NULL;--> statement-breakpoint ALTER TABLE `snippets_table` ALTER COLUMN "userId" TO "userId" text NOT NULL REFERENCES users_table(id) ON DELETE no action ON UPDATE no action; \ No newline at end of file diff --git a/playgrounds/app/src/routes/index.tsx b/playgrounds/app/src/routes/index.tsx index 99f805a..8fbdd19 100644 --- a/playgrounds/app/src/routes/index.tsx +++ b/playgrounds/app/src/routes/index.tsx @@ -36,7 +36,7 @@ export default function Home() { const [endCode, setEndCode] = makePersisted(createSignal(right), { name: 'endCode', }) - const [bgColor, setBgColor] = makePersisted(createSignal('#ffffff'), { + const [bgColor, setBgColor] = makePersisted(createSignal('#a3d0ff'), { name: 'bgColor', }) const [xPadding, setXPadding] = makePersisted(createSignal(42), {