Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,72 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import mdx from '@astrojs/mdx'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'astro/config'

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const thoughtsCanvasLayoutPath = path.resolve(__dirname, 'src/data/thoughts-canvas-layout.json')

function thoughtsCanvasLayoutDevPlugin() {
return {
name: 'thoughts-canvas-layout-dev',
configureServer(server) {
server.middlewares.use('/__thoughts-canvas-layout', async (req, res, next) => {
if (req.method !== 'POST')
return next()

try {
let body = ''
for await (const chunk of req)
body += chunk
Comment on lines +20 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When reading from a Node.js readable stream (like req), chunks are emitted as Buffer objects by default. Concatenating them directly to a string (body += chunk) implicitly calls toString() on each chunk. If a multi-byte UTF-8 character happens to be split across a chunk boundary, this can result in encoding corruption.

It is safer to collect the buffers in an array and concatenate them using Buffer.concat() before converting to a string.

Suggested change
let body = ''
for await (const chunk of req)
body += chunk
const chunks = []
for await (const chunk of req)
chunks.push(chunk)
const body = Buffer.concat(chunks).toString('utf8')


const payload = JSON.parse(body)
if (payload?.version !== 1 || typeof payload.cards !== 'object' || payload.cards === null)
throw new Error('Invalid thoughts layout payload')

const cards = {}
for (const [slug, card] of Object.entries(payload.cards)) {
if (
typeof slug !== 'string'
|| typeof card !== 'object'
|| card === null
|| !Number.isFinite(card.x)
|| !Number.isFinite(card.y)
|| !Number.isFinite(card.rotateDeg)
) {
throw new Error(`Invalid card layout for ${slug}`)
}
cards[slug] = {
x: Math.round(card.x * 100) / 100,
y: Math.round(card.y * 100) / 100,
rotateDeg: Math.round(card.rotateDeg * 100) / 100,
}
}

const sortedCards = Object.fromEntries(Object.entries(cards).sort(([a], [b]) => a.localeCompare(b)))
await fs.writeFile(
thoughtsCanvasLayoutPath,
`${JSON.stringify({ version: 1, cards: sortedCards }, null, 2)}\n`,
'utf8',
)

const layoutModules = server.moduleGraph.getModulesByFile(thoughtsCanvasLayoutPath)
layoutModules?.forEach(mod => server.moduleGraph.invalidateModule(mod))

res.statusCode = 200
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ ok: true, layout: { version: 1, cards: sortedCards } }))
}
catch (err) {
res.statusCode = 400
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ ok: false, error: err instanceof Error ? err.message : 'Unknown error' }))
}
})
},
}
}

// https://astro.build/config
export default defineConfig({
Expand Down Expand Up @@ -37,7 +99,12 @@ export default defineConfig({
],

vite: {
plugins: [tailwindcss()],
plugins: [tailwindcss(), thoughtsCanvasLayoutDevPlugin()],
server: {
watch: {
ignored: [thoughtsCanvasLayoutPath],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
Expand Down
25 changes: 25 additions & 0 deletions src/components/icons/RotateCcw.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
interface Props {
class?: string
size?: number
}

const { class: className = '', size = 14 } = Astro.props
---

<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class:list={['lucide lucide-rotate-ccw', className]}
aria-hidden="true"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
<path d="M3 3v5h5"></path>
</svg>
25 changes: 25 additions & 0 deletions src/components/icons/RotateCw.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
interface Props {
class?: string
size?: number
}

const { class: className = '', size = 14 } = Astro.props
---

<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class:list={['lucide lucide-rotate-cw', className]}
aria-hidden="true"
>
<path d="M21 12a9 9 0 1 1-9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path>
<path d="M21 3v5h-5"></path>
</svg>
111 changes: 90 additions & 21 deletions src/components/thoughts/ThoughtsCanvasBottomToolbar.astro
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
---
import RotateCcw from '@/components/icons/RotateCcw.astro'
import RotateCw from '@/components/icons/RotateCw.astro'

interface Props {
isDev?: boolean
}

const { isDev = false } = Astro.props
---

<div
id="thoughts-canvas-bottom-toolbar"
class="motion-reduce:hidden pointer-events-none fixed inset-x-0 bottom-[max(1.75rem,calc(env(safe-area-inset-bottom)+0.75rem))] z-40 hidden justify-center px-4 max-md:flex md:motion-safe:flex"
data-editable={isDev ? 'true' : 'false'}
class:list={[
'motion-reduce:hidden pointer-events-none fixed inset-x-0 z-40 hidden justify-center px-4 max-md:flex md:motion-safe:flex',
isDev
? 'bottom-[max(4.75rem,calc(env(safe-area-inset-bottom)+4rem))]'
: 'bottom-[max(1.75rem,calc(env(safe-area-inset-bottom)+0.75rem))]',
]}
aria-label="Thoughts canvas controls"
>
<div
Expand Down Expand Up @@ -34,6 +48,59 @@

<div class="mx-0.5 hidden h-5 w-px bg-gray-200/90 sm:block" aria-hidden="true"></div>

{
isDev && (
<>
<div
class="flex items-center gap-0.5 rounded-sm border border-gray-200/80 bg-white/80 px-1 py-0.5"
aria-label="Canvas edit"
>
<button
type="button"
id="thoughts-canvas-edit"
class="cursor-pointer rounded-sm px-2 py-1 text-[11px] text-gray-700 transition-colors hover:bg-gray-100 sm:text-xs"
aria-pressed="false"
>
Edit
</button>
<button
type="button"
id="thoughts-canvas-rotate-left"
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-sm text-sm leading-none text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-35"
aria-label="Rotate left"
disabled
>
<RotateCcw />
</button>
<button
type="button"
id="thoughts-canvas-rotate-right"
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-sm text-sm leading-none text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-35"
aria-label="Rotate right"
disabled
>
<RotateCw />
</button>
<button
type="button"
id="thoughts-canvas-save"
class="cursor-pointer rounded-sm px-2 py-1 text-[11px] text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-35 sm:text-xs"
disabled
>
Save
</button>
<span
id="thoughts-canvas-edit-status"
class="min-w-[3.75rem] select-none px-1 text-gray-500"
aria-live="polite"
></span>
</div>

<div class="mx-0.5 hidden h-5 w-px bg-gray-200/90 sm:block" aria-hidden="true"></div>
</>
)
}

<div
id="thoughts-canvas-zoom-cluster"
class="flex items-center gap-0.5 rounded-sm border border-gray-200/80 bg-white/80 px-1 py-0.5 tabular-nums"
Expand Down Expand Up @@ -77,16 +144,6 @@ function qs<T extends HTMLElement>(sel: string) {
return document.querySelector<T>(sel)
}

function chromeOpts(): ThoughtsCanvasChromeOptions {
return {
zoomOutBtn: qs<HTMLButtonElement>('#thoughts-canvas-zoom-out'),
zoomInBtn: qs<HTMLButtonElement>('#thoughts-canvas-zoom-in'),
zoomLevelEl: qs<HTMLElement>('#thoughts-canvas-zoom-level'),
resetBtn: qs<HTMLButtonElement>('#thoughts-canvas-reset'),
initialScale: window.innerWidth < 1024 ? 0.75 : 1,
}
}

const root = qs<HTMLElement>('#thoughts-root')
const toolbar = qs<HTMLElement>('#thoughts-canvas-bottom-toolbar')
const cardBtn = qs<HTMLButtonElement>('#thoughts-mode-card')
Expand All @@ -99,11 +156,27 @@ const zoomInEl = qs<HTMLButtonElement>('#thoughts-canvas-zoom-in')
const zoomLevelEl = qs<HTMLElement>('#thoughts-canvas-zoom-level')
const resetBtn = qs<HTMLButtonElement>('#thoughts-canvas-reset')

function chromeOpts(): ThoughtsCanvasChromeOptions {
return {
zoomOutBtn: qs<HTMLButtonElement>('#thoughts-canvas-zoom-out'),
zoomInBtn: qs<HTMLButtonElement>('#thoughts-canvas-zoom-in'),
zoomLevelEl: qs<HTMLElement>('#thoughts-canvas-zoom-level'),
resetBtn: qs<HTMLButtonElement>('#thoughts-canvas-reset'),
editBtn: qs<HTMLButtonElement>('#thoughts-canvas-edit'),
rotateLeftBtn: qs<HTMLButtonElement>('#thoughts-canvas-rotate-left'),
rotateRightBtn: qs<HTMLButtonElement>('#thoughts-canvas-rotate-right'),
saveBtn: qs<HTMLButtonElement>('#thoughts-canvas-save'),
editStatusEl: qs<HTMLElement>('#thoughts-canvas-edit-status'),
enableEditing: toolbar?.dataset.editable === 'true',
initialScale: window.innerWidth < 1024 ? 0.75 : 1,
}
}

if (root && toolbar && cardBtn && canvasBtn && viewport && world && zoomCluster && zoomOutEl && zoomInEl && zoomLevelEl && resetBtn) {
let activeCanvas: ReturnType<typeof initThoughtsCanvas> | null = null

const mqDesktopCanvas = window.matchMedia('(min-width: 768px) and (prefers-reduced-motion: no-preference)')
let currentMode: 'canvas' | 'card' = mqDesktopCanvas.matches ? 'canvas' : 'card'
const mqCanvasAllowed = window.matchMedia('(prefers-reduced-motion: no-preference)')
let currentMode: 'canvas' | 'card' = mqCanvasAllowed.matches ? 'canvas' : 'card'

function setModeUi(mode: 'card' | 'canvas') {
const onCanvas = mode === 'canvas'
Expand All @@ -129,8 +202,8 @@ if (root && toolbar && cardBtn && canvasBtn && viewport && world && zoomCluster
}
}

function setViewMode(mode: 'card' | 'canvas') {
if (mode === currentMode)
function setViewMode(mode: 'card' | 'canvas', force = false) {
if (!force && mode === currentMode)
return
currentMode = mode

Expand All @@ -151,14 +224,10 @@ if (root && toolbar && cardBtn && canvasBtn && viewport && world && zoomCluster
cardBtn!.addEventListener('click', () => setViewMode('card'))
canvasBtn!.addEventListener('click', () => setViewMode('canvas'))

mqDesktopCanvas.addEventListener('change', (e) => {
mqCanvasAllowed.addEventListener('change', (e) => {
setViewMode(e.matches ? 'canvas' : 'card')
})

if (currentMode === 'canvas') {
if (!activeCanvas)
activeCanvas = initThoughtsCanvas(viewport!, world!, chromeOpts())
}
setModeUi(currentMode)
setViewMode(currentMode, true)
}
</script>
Loading
Loading