From 3ef531d722b7304b009d6ada600c32d5fbf9d2e5 Mon Sep 17 00:00:00 2001 From: DuroCodes Date: Fri, 20 Mar 2026 16:36:50 -0400 Subject: [PATCH] feat: add word wrapping, make editor tabs behave more like a browser tab bar --- src/app/[id]/page.tsx | 5 +- src/app/globals.css | 13 ++- src/components/editor-provider.tsx | 8 +- src/components/editor-tabs.tsx | 147 +++++++++++++++------------ src/components/header.tsx | 158 ++++++++++++++++++----------- src/components/monaco-editor.tsx | 17 +++- src/utils/paste-tabs.ts | 7 +- 7 files changed, 213 insertions(+), 142 deletions(-) diff --git a/src/app/[id]/page.tsx b/src/app/[id]/page.tsx index a82ab25..347915d 100644 --- a/src/app/[id]/page.tsx +++ b/src/app/[id]/page.tsx @@ -4,10 +4,7 @@ import { MonacoEditor } from "~/components/monaco-editor"; import { getPasteById } from "~/actions/paste"; import { Header } from "~/components/header"; import { LANGUAGES_SET, type LanguageName } from "~/utils/languages"; -import { - createEmptyTab, - normalizeTabs, -} from "~/utils/paste-tabs"; +import { createEmptyTab, normalizeTabs } from "~/utils/paste-tabs"; interface Props { params: Promise<{ id: string }>; diff --git a/src/app/globals.css b/src/app/globals.css index 46e9d8e..95da7a8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -74,7 +74,8 @@ } * { - font-family: "JetBrains Mono", "SF Mono", "Monaco", "Inconsolata", "Fira Code", + font-family: + "JetBrains Mono", "SF Mono", "Monaco", "Inconsolata", "Fira Code", "Fira Mono", "Droid Sans Mono", "Source Code Pro", "Consolas", "DejaVu Sans Mono", monospace; font-feature-settings: "cv11", "cv13"; @@ -123,3 +124,13 @@ @apply bg-background text-foreground; } } + +@layer utilities { + .scrollbar-none { + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + } +} diff --git a/src/components/editor-provider.tsx b/src/components/editor-provider.tsx index 36f07b7..95db728 100644 --- a/src/components/editor-provider.tsx +++ b/src/components/editor-provider.tsx @@ -28,6 +28,8 @@ interface EditorContextType { closeTab: (tabId: string) => void; theme: string; setTheme: (theme: string) => void; + wordWrap: boolean; + setWordWrap: (value: boolean) => void; } const EditorContext = createContext(undefined); @@ -52,6 +54,7 @@ export function EditorProvider({ initialActiveTabId ?? initialTabs?.[0]?.id ?? "", ); const [theme, setTheme] = useState(initialTheme); + const [wordWrap, setWordWrap] = useState(false); const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0]!; @@ -133,6 +136,8 @@ export function EditorProvider({ closeTab, theme, setTheme, + wordWrap, + setWordWrap, }} > {children} @@ -142,7 +147,8 @@ export function EditorProvider({ export function useEditor() { const context = useContext(EditorContext); - if (context === undefined) + + if (!context) throw new Error("useEditor must be used within an EditorProvider"); return context; diff --git a/src/components/editor-tabs.tsx b/src/components/editor-tabs.tsx index 123dbcd..7ca9ab6 100644 --- a/src/components/editor-tabs.tsx +++ b/src/components/editor-tabs.tsx @@ -3,11 +3,9 @@ import { Plus, X } from "lucide-react"; import { useEditor } from "~/components/editor-provider"; import { SearchableSelect } from "~/components/searchable-select"; +import { Button } from "~/components/ui/button"; import { cn } from "~/utils/cn"; -const iconBtnClass = - "border-input dark:bg-input/30 text-muted-foreground hover:text-foreground inline-flex h-9 w-9 items-center justify-center rounded-md border bg-background shadow-xs transition-colors"; - export function EditorTabs() { const { tabs, @@ -23,117 +21,132 @@ export function EditorTabs() { return (
- {hasMultipleTabs ? ( - ({ - value: tab.id, - label: tab.filename, - }))} - placeholder="files" - value={activeTabId} - onValueChange={setActiveTabId} - onPreview={setActiveTabId} - className="min-w-0 flex-1" - /> - ) : ( - - {activeTab.filename} - - )} + ({ + value: tab.id, + label: tab.filename, + }))} + placeholder="files" + value={activeTabId} + onValueChange={setActiveTabId} + onPreview={setActiveTabId} + className="min-w-0 flex-1" + /> - {hasMultipleTabs && ( - - )} - - + + + + {hasMultipleTabs && ( + + )}
-
-
+
+
{hasMultipleTabs && tabs.map((tab) => { const isActive = tab.id === activeTabId; + const nameForWidth = isActive ? activeTab.filename : tab.filename; + const labelWidthCh = Math.min( + 24, + Math.max(10, nameForWidth.length + 3), + ); + const labelWidthStyle = { + width: `${labelWidthCh}ch`, + minWidth: `${labelWidthCh}ch`, + } as const; return (
{isActive ? ( updateActiveTabFilename(event.target.value) } onClick={(event) => event.stopPropagation()} - className="placeholder:text-primary-foreground/70 bg-transparent px-3 text-sm font-medium outline-none" - style={{ - width: `${Math.min( - 24, - Math.max(10, activeTab.filename.length + 3), - )}ch`, - }} + className="placeholder:text-primary-foreground/70 min-w-0 bg-transparent px-3 py-0 text-sm font-medium leading-none outline-none focus-visible:ring-0" + style={labelWidthStyle} placeholder="file.ts" aria-label="Filename" /> ) : ( - + )} - + +
); })} - +
+ +
diff --git a/src/components/header.tsx b/src/components/header.tsx index 1f322cf..bccf5a7 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -1,5 +1,6 @@ "use client"; +import { WrapText } from "lucide-react"; import Link from "next/link"; import { useEditor } from "./editor-provider"; import { EditorTabs } from "./editor-tabs"; @@ -11,73 +12,108 @@ import { SaveButton } from "./save-button"; import { Icons } from "./icons"; import { cn } from "~/utils/cn"; -export function Header() { - const { tabs, activeTab, theme, updateActiveTabLanguage, setTheme } = - useEditor(); +function LanguageThemeControls() { + const { activeTab, theme, updateActiveTabLanguage, setTheme } = useEditor(); const setLanguage = (language: string) => { if (LANGUAGES_SET.has(language)) updateActiveTabLanguage(language); }; return ( -
-
-
- - - + <> + ({ + value: language, + label: language, + }))} + placeholder="language" + value={activeTab.language} + onValueChange={setLanguage} + onPreview={setLanguage} + className="w-full min-w-0 sm:w-40" + /> - - - -
- -
- -
- -
- ({ - value: language, - label: language, - }))} - placeholder="language" - value={activeTab.language} - onValueChange={setLanguage} - onPreview={setLanguage} - className="w-full xl:w-40" - /> - - ({ - value: currentTheme, - label: currentTheme, - }))} - placeholder="theme" - value={theme} - onValueChange={setTheme} - onPreview={setTheme} - className="w-full xl:w-52" - /> -
-
-
+ ({ + value: currentTheme, + label: currentTheme, + }))} + placeholder="theme" + value={theme} + onValueChange={setTheme} + onPreview={setTheme} + className="w-full min-w-0 sm:w-52" + /> + + ); +} + +export function Header() { + const { tabs, theme, wordWrap, setWordWrap } = useEditor(); + + return ( + <> +
+
+
+ + + + + + + + + +
+ +
+ +
+ +
+ +
+
+
+ +
+ +
+ ); } diff --git a/src/components/monaco-editor.tsx b/src/components/monaco-editor.tsx index b6ff91a..8c01e94 100644 --- a/src/components/monaco-editor.tsx +++ b/src/components/monaco-editor.tsx @@ -4,7 +4,7 @@ import { AutoTypings, LocalStorageCache, } from "monaco-editor-auto-typings/custom-editor"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Editor, type Monaco } from "@monaco-editor/react"; import { shikiToMonaco } from "@shikijs/monaco"; import { createHighlighter } from "shiki"; @@ -13,8 +13,19 @@ import { useEditor } from "./editor-provider"; import { THEME_MAP } from "~/utils/themes"; export function MonacoEditor() { - const { activeTab, theme, updateActiveTabContent } = useEditor(); + const { + activeTab, + activeTabId, + closeTab, + theme, + wordWrap, + updateActiveTabContent, + } = useEditor(); const [isLoading, setIsLoading] = useState(true); + const activeTabIdRef = useRef(activeTabId); + activeTabIdRef.current = activeTabId; + const closeTabRef = useRef(closeTab); + closeTabRef.current = closeTab; useEffect(() => { const colors = THEME_MAP[theme]?.ui; @@ -87,7 +98,7 @@ export function MonacoEditor() { }} options={{ fontSize: 14, - wordWrap: "off", + wordWrap: wordWrap ? "on" : "off", minimap: { enabled: false }, automaticLayout: true, bracketPairColorization: { diff --git a/src/utils/paste-tabs.ts b/src/utils/paste-tabs.ts index f89d2b1..52e06d5 100644 --- a/src/utils/paste-tabs.ts +++ b/src/utils/paste-tabs.ts @@ -54,13 +54,10 @@ export const LANGUAGE_EXTENSIONS: Record = { sfm: "sfm", }; -const toSafeExtension = (language: LanguageName) => - language.toLowerCase().replace(/[^a-z0-9]+/g, ""); - export const LANGUAGE_TO_EXTENSION = Object.fromEntries( LANGUAGES.map((language) => [ language, - LANGUAGE_EXTENSIONS[language] ?? toSafeExtension(language), + LANGUAGE_EXTENSIONS[language] ?? language.toLowerCase(), ]), ) as Record; @@ -77,7 +74,7 @@ export const inferLanguage = (filename: string) => { const extension = filename.trim().split(".").pop()?.toLowerCase(); if (!extension || extension === filename.trim().toLowerCase()) return null; - return EXTENSION_TO_LANGUAGE[extension] ?? null; + return EXTENSION_TO_LANGUAGE[extension] ?? "text"; }; export const replaceFilenameExtension = (