Skip to content
Merged
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
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
5 changes: 4 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
"name": "@mydevtools/web",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=20.19.0"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"analyze": "ANALYZE=true next build",
"analyze": "ANALYZE=true next build --webpack",
"start": "next start",
"lint": "eslint .",
"test": "jest",
Expand Down
42 changes: 0 additions & 42 deletions apps/web/src/app/app/to-do/utils/exportUtils.ts

This file was deleted.

28 changes: 18 additions & 10 deletions apps/web/src/components/json-formatter/json-formatter-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
IconRefresh,
} from '@tabler/icons-react'
import { toast } from 'sonner'
import { Mode, toTextContent, type Content, type OnChangeStatus } from 'vanilla-jsoneditor'
import type { Mode, Content, OnChangeStatus } from 'vanilla-jsoneditor'
import { fetchAllPages } from '@/lib/fetch-all-pages'
import { ToolPageHeader } from '@/components/tools/tool-page-header'
import { ToolMobileTabs } from '@/components/tools/tool-mobile-tabs'
Expand All @@ -38,6 +38,14 @@ import {
} from '@/components/ui/responsive-modal'
import { ScrollArea } from '@/components/ui/scroll-area'

// Local copy of vanilla-jsoneditor's toTextContent, kept out of the static import so the
// 485KB editor barrel isn't dragged into this route's initial chunk (editor loads lazily).
function contentToText(content: Content): string {
if ('text' in content && content.text !== undefined) return content.text
// vanilla-jsoneditor's toTextContent defaults to compact (no indentation) — match it.
return JSON.stringify((content as { json: unknown }).json)
}

const initialJson = {
array: [1, 2, 3],
boolean: true,
Expand Down Expand Up @@ -179,8 +187,8 @@ export function JsonFormatterLayout() {

const handleCopy = (pane: PaneKey) => {
const paneState = pane === 'left' ? leftPane : rightPane
const textContent = toTextContent(paneState.content)
void copyToClipboard(textContent.text, {
const textContentText = contentToText(paneState.content)
void copyToClipboard(textContentText, {
successMessage: pane === 'left' ? t('toastCopiedText') : t('toastCopiedTree'),
errorMessage: t('toastCopyFailed'),
})
Expand All @@ -195,11 +203,11 @@ export function JsonFormatterLayout() {
const paneState = pane === 'left' ? leftPane : rightPane
try {
updatePane(pane, (prev) => ({ ...prev, isSaving: true }))
const textContent = toTextContent(paneState.content)
const textContentText = contentToText(paneState.content)
const body = {
title: paneState.documentName,
pane,
content: textContent.text,
content: textContentText,
}

if (paneState.documentId) {
Expand Down Expand Up @@ -306,7 +314,7 @@ export function JsonFormatterLayout() {
<IconCopy className="mr-1.5 h-4 w-4" />
{t('copy')}
</Button>
<SendToMenu content={toTextContent(state.content).text} />
<SendToMenu content={contentToText(state.content)} />
<Button variant="outline" size="sm" onClick={() => openLoadDialog(pane)}>
<IconFolderOpen className="mr-1.5 h-4 w-4" />
{t.has('load') ? t('load') : 'Load'}
Expand Down Expand Up @@ -345,7 +353,7 @@ export function JsonFormatterLayout() {
<div className="min-h-0 flex-1">
{activePane === 'left' ? (
<VanillaEditor
mode={Mode.text}
mode={'text' as Mode}
content={leftPane.content}
onChange={(updated, previous, status) =>
handlePaneChange('left', updated, previous, status)
Expand All @@ -356,7 +364,7 @@ export function JsonFormatterLayout() {
/>
) : (
<VanillaEditor
mode={Mode.tree}
mode={'tree' as Mode}
content={rightPane.content}
onChange={(updated, previous, status) =>
handlePaneChange('right', updated, previous, status)
Expand All @@ -378,7 +386,7 @@ export function JsonFormatterLayout() {
{renderPaneToolbar('left')}
<div className="min-h-0 flex-1">
<VanillaEditor
mode={Mode.text}
mode={'text' as Mode}
content={leftPane.content}
onChange={(updated, previous, status) =>
handlePaneChange('left', updated, previous, status)
Expand All @@ -398,7 +406,7 @@ export function JsonFormatterLayout() {
{renderPaneToolbar('right')}
<div className="min-h-0 flex-1">
<VanillaEditor
mode={Mode.tree}
mode={'tree' as Mode}
content={rightPane.content}
onChange={(updated, previous, status) =>
handlePaneChange('right', updated, previous, status)
Expand Down
18 changes: 11 additions & 7 deletions apps/web/src/components/json-formatter/vanilla-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client'

import { useEffect, useRef, useState } from 'react'
import { JSONEditor, JSONEditorPropsOptional } from 'vanilla-jsoneditor'
import type { JSONEditorPropsOptional } from 'vanilla-jsoneditor'
import { useTheme } from 'next-themes'
import { cn } from '@/lib/utils'
import 'vanilla-jsoneditor/themes/jse-theme-dark.css'
Expand All @@ -12,7 +12,9 @@ interface VanillaEditorProps extends JSONEditorPropsOptional {

export function VanillaEditor({ className = '', ...props }: VanillaEditorProps) {
const refContainer = useRef<HTMLDivElement>(null)
const refEditor = useRef<ReturnType<typeof JSONEditor> | null>(null)
const refEditor = useRef<ReturnType<typeof import('vanilla-jsoneditor').JSONEditor> | null>(null)
const propsRef = useRef(props)
propsRef.current = props
const { resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)

Expand All @@ -23,22 +25,24 @@ export function VanillaEditor({ className = '', ...props }: VanillaEditorProps)
const isDarkTheme = mounted && resolvedTheme === 'dark'

useEffect(() => {
// create editor
if (refContainer.current && !refEditor.current) {
let disposed = false
// vanilla-jsoneditor is ~485KB; load it on mount so the tool page paints without it.
void import('vanilla-jsoneditor').then(({ JSONEditor }) => {
if (disposed || !refContainer.current || refEditor.current) return
refEditor.current = JSONEditor({
target: refContainer.current,
props,
props: propsRef.current,
})
}
})

return () => {
// destroy editor
disposed = true
if (refEditor.current) {
refEditor.current.destroy()
refEditor.current = null
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

// update props
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { format, type KeywordCase, type SqlLanguage } from 'sql-formatter';
import type { KeywordCase, SqlLanguage } from 'sql-formatter';
import { useCallback, useState } from 'react';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { Button } from '@/components/ui/button';
Expand Down Expand Up @@ -51,7 +51,7 @@ export function SqlFormatterLayout() {
const [error, setError] = useState('');
const { isCopied: copied, copyToClipboard, reset: resetCopied } = useCopyToClipboard();

const runFormat = useCallback(() => {
const runFormat = useCallback(async () => {
setError('');
resetCopied();
if (isMobile) setMobileTab('output');
Expand All @@ -67,6 +67,8 @@ export function SqlFormatterLayout() {
}
try {
const tw = Math.min(8, Math.max(1, parseInt(tabWidth, 10) || 2));
// sql-formatter is ~278KB; load on first format so the tool page paints without it.
const { format } = await import('sql-formatter');
setOutput(
format(q, {
language: dialect,
Expand Down
26 changes: 16 additions & 10 deletions apps/web/src/components/svg-optimizer/svg-optimizer-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { Textarea } from '@/components/ui/textarea'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Check, Copy, Download, Trash2, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
import { optimizeSvgMarkup, utf8ByteLength } from '@/lib/svg-optimize'
import { utf8ByteLength } from '@/lib/svg-optimize'
import { useSvgOptimizeWorker } from '@/hooks/use-svg-optimize-worker'

const SAMPLE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120">
<!-- demo: metadata + whitespace -->
Expand All @@ -30,17 +31,22 @@ export function SvgOptimizerLayout() {
const [output, setOutput] = useState('')
const [error, setError] = useState<string | null>(null)
const { isCopied: copied, copyToClipboard } = useCopyToClipboard()
const { optimize } = useSvgOptimizeWorker()

const runOptimize = useCallback((raw: string) => {
const result = optimizeSvgMarkup(raw)
if (result.ok) {
setOutput(result.data)
setError(null)
} else {
setOutput('')
setError(result.error)
const runOptimize = useCallback(async (raw: string) => {
try {
const result = await optimize(raw)
if (result.ok) {
setOutput(result.data)
setError(null)
} else {
setOutput('')
setError(result.error)
}
} catch {
// worker unmounted or failed mid-flight — component is going away, ignore
}
}, [])
}, [optimize])

const debouncedOptimize = useDebouncedCallback(runOptimize, 280)

Expand Down
86 changes: 86 additions & 0 deletions apps/web/src/hooks/use-svg-optimize-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
'use client';

import { useCallback, useEffect, useRef } from 'react';
import { optimizeSvgMarkup, shouldUseSvgWorker } from '@/lib/svg-optimize';
import type { SvgOptimizeRequest, SvgOptimizeResponse } from '@/workers/svg-optimize.worker';

type SvgOptimizeResult = Awaited<ReturnType<typeof optimizeSvgMarkup>>;

type PendingRequest = {
resolve: (result: SvgOptimizeResult) => void;
reject: (error: Error) => void;
};

/**
* SVG optimization that moves off the main thread for large documents.
*
* - SVGs >= SVG_WORKER_MIN_CHARS route through a lazily-created Web Worker
* (terminated on unmount) so svgo's parse/optimize never blocks typing.
* - Small SVGs, SSR/no-Worker environments, and a previously-failed worker
* fall back to the same optimizeSvgMarkup on the main thread.
*/
export function useSvgOptimizeWorker() {
const workerRef = useRef<Worker | null>(null);
const workerFailedRef = useRef(false);
const pendingRef = useRef(new Map<number, PendingRequest>());
const nextIdRef = useRef(0);

const getWorker = useCallback((): Worker | null => {
if (workerFailedRef.current || typeof Worker === 'undefined') return null;
if (!workerRef.current) {
const worker = new Worker(new URL('../workers/svg-optimize.worker.ts', import.meta.url), {
type: 'module',
});
worker.onmessage = (event: MessageEvent<SvgOptimizeResponse>) => {
const pending = pendingRef.current.get(event.data.id);
if (!pending) return;
pendingRef.current.delete(event.data.id);
const { id: _id, ...result } = event.data;
pending.resolve(result);
};
worker.onerror = () => {
// Worker failed to load or crashed: fail everything in flight and
// route all future calls through the main-thread fallback.
for (const pending of pendingRef.current.values()) {
pending.reject(new Error('svg optimize worker failed'));
}
pendingRef.current.clear();
worker.terminate();
workerRef.current = null;
workerFailedRef.current = true;
};
workerRef.current = worker;
}
return workerRef.current;
}, []);

const optimize = useCallback(
(svg: string): Promise<SvgOptimizeResult> => {
const worker = shouldUseSvgWorker(svg, typeof Worker !== 'undefined') ? getWorker() : null;
if (!worker) return optimizeSvgMarkup(svg);
const id = nextIdRef.current++;
return new Promise<SvgOptimizeResult>((resolve, reject) => {
pendingRef.current.set(id, { resolve, reject });
const request: SvgOptimizeRequest = { id, svg };
worker.postMessage(request);
});
},
[getWorker],
);

useEffect(() => {
const pending = pendingRef.current;
return () => {
// Reject in-flight requests before clearing so any caller still awaiting
// optimize() settles instead of hanging forever (mirrors onerror above).
for (const request of pending.values()) {
request.reject(new Error('svg optimize worker unmounted'));
}
workerRef.current?.terminate();
workerRef.current = null;
pending.clear();
};
}, []);

return { optimize };
}
14 changes: 12 additions & 2 deletions apps/web/src/lib/svg-optimize.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { optimize, type Config } from 'svgo/browser'
import type { Config } from 'svgo/browser'

/** Preset-default keeps responsive icons working; multipass squeezes extra wins. */
const SVGO_OPTIONS = {
Expand All @@ -10,12 +10,22 @@ export function utf8ByteLength(text: string): number {
return new TextEncoder().encode(text).length
}

export function optimizeSvgMarkup(svg: string): { ok: true; data: string } | { ok: false; error: string } {
// Below this, main-thread optimize beats worker round-trip + svgo chunk load.
// ponytail: fixed char threshold; tune if profiling shows jank on smaller SVGs.
export const SVG_WORKER_MIN_CHARS = 15_000

export function shouldUseSvgWorker(svg: string, hasWorker: boolean): boolean {
return hasWorker && svg.length >= SVG_WORKER_MIN_CHARS
}

// svgo is ~525KB; load it on first optimize so the tool page paints without it.
export async function optimizeSvgMarkup(svg: string): Promise<{ ok: true; data: string } | { ok: false; error: string }> {
const trimmed = svg.trim()
if (!trimmed) {
return { ok: true, data: '' }
}
try {
const { optimize } = await import('svgo/browser')
const { data } = optimize(trimmed, SVGO_OPTIONS as Config)
return { ok: true, data }
} catch (e) {
Expand Down
Loading