feat: add word wrapping, make editor tabs behave more like a browser tab bar

This commit is contained in:
DuroCodes
2026-03-20 16:36:50 -04:00
parent e2e702829a
commit 3ef531d722
7 changed files with 213 additions and 142 deletions

View File

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

View File

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

View File

@@ -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<EditorContextType | undefined>(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;

View File

@@ -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 (
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 sm:hidden">
{hasMultipleTabs ? (
<SearchableSelect
options={tabs.map((tab) => ({
value: tab.id,
label: tab.filename,
}))}
placeholder="files"
value={activeTabId}
onValueChange={setActiveTabId}
onPreview={setActiveTabId}
className="min-w-0 flex-1"
/>
) : (
<span className="text-muted-foreground block min-w-0 flex-1 truncate px-2 text-sm font-medium">
{activeTab.filename}
</span>
)}
<SearchableSelect
options={tabs.map((tab) => ({
value: tab.id,
label: tab.filename,
}))}
placeholder="files"
value={activeTabId}
onValueChange={setActiveTabId}
onPreview={setActiveTabId}
className="min-w-0 flex-1"
/>
{hasMultipleTabs && (
<button
type="button"
onClick={() => closeTab(activeTabId)}
className={iconBtnClass}
aria-label={`Close ${activeTab.filename}`}
>
<X className="h-4 w-4" />
</button>
)}
<button
<Button
type="button"
variant="outline"
size="icon"
className="relative z-10 shrink-0"
onClick={addTab}
className={iconBtnClass}
aria-label="Add tab"
>
<Plus className="h-4 w-4" />
</button>
<Plus />
</Button>
{hasMultipleTabs && (
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
onClick={() => closeTab(activeTabId)}
aria-label={`Close ${activeTab.filename}`}
>
<X />
</Button>
)}
</div>
<div className="hidden min-w-0 flex-1 overflow-x-auto sm:block">
<div className="flex min-w-max items-center gap-2">
<div className="scrollbar-none hidden min-h-0 min-w-0 flex-1 overflow-x-auto overflow-y-hidden sm:block">
<div className="inline-flex min-w-max items-center gap-2">
{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 (
<div
key={tab.id}
className={cn(
"border-input dark:bg-input/30 bg-background text-foreground flex h-9 items-center gap-1 rounded-md border pr-0.5 text-sm shadow-xs transition-colors",
"border-input dark:bg-input/30 bg-background text-foreground box-border inline-flex h-9 shrink-0 items-center gap-1 rounded-md border pr-0.5 text-sm transition-colors",
isActive &&
"bg-primary text-primary-foreground border-transparent",
"border-primary bg-primary text-primary-foreground",
)}
>
{isActive ? (
<input
value={activeTab.filename}
spellCheck={false}
onChange={(event) =>
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"
/>
) : (
<button
<Button
type="button"
variant="ghost"
className={cn(
"text-foreground h-full min-h-0 min-w-0 justify-start rounded-none px-3 py-0 text-left text-sm font-medium leading-none shadow-none",
"hover:bg-transparent hover:text-foreground",
"focus-visible:ring-0 focus-visible:ring-offset-0",
)}
style={labelWidthStyle}
onClick={() => setActiveTabId(tab.id)}
className="h-full px-3 text-sm font-medium"
>
<span className="text-muted-foreground block max-w-40 truncate">
<span className="block min-w-0 truncate">
{tab.filename}
</span>
</button>
</Button>
)}
<button
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 min-h-8 min-w-8 shrink-0 transition-colors hover:bg-transparent dark:hover:bg-transparent",
isActive
? "text-primary-foreground hover:text-primary-foreground/90"
: "text-muted-foreground hover:text-primary",
)}
onClick={(event) => {
event.stopPropagation();
closeTab(tab.id);
}}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors",
isActive
? "text-primary-foreground/80 hover:text-primary-foreground"
: "text-muted-foreground hover:text-foreground",
)}
aria-label={`Close ${tab.filename}`}
>
<X className="h-3.5 w-3.5" />
</button>
<X className="size-3.5" />
</Button>
</div>
);
})}
<button
type="button"
onClick={addTab}
className={iconBtnClass}
aria-label="Add tab"
>
<Plus className="h-4 w-4" />
</button>
<div className="bg-background sticky right-0 z-10 shrink-0">
<Button
type="button"
variant="outline"
size="icon"
className="bg-background"
onClick={addTab}
aria-label="Add tab"
>
<Plus />
</Button>
</div>
</div>
</div>
</div>

View File

@@ -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 (
<header className="border-border/70 bg-background shrink-0 border-b">
<div className="flex flex-col gap-2 px-4 py-3 xl:flex-row xl:items-center">
<div className="grid w-full grid-cols-5 gap-2 sm:flex sm:w-auto xl:shrink-0">
<Link
className={cn(
buttonVariants({ variant: "outline" }),
"col-span-1 p-2 sm:w-auto",
)}
href="https://github.com/durocodes/spongebin"
>
<Icons.GitHub className="mx-auto h-4 w-4" />
</Link>
<>
<SearchableSelect
options={LANGUAGES.map((language) => ({
value: language,
label: language,
}))}
placeholder="language"
value={activeTab.language}
onValueChange={setLanguage}
onPreview={setLanguage}
className="w-full min-w-0 sm:w-40"
/>
<Button
variant="outline"
onClick={() => (location.href = "/")}
className="col-span-2 sm:w-auto"
>
new
</Button>
<SaveButton
tabs={tabs}
theme={theme}
className="col-span-2 sm:w-auto"
/>
</div>
<div className="w-full xl:min-w-0 xl:flex-1">
<EditorTabs />
</div>
<div className="grid w-full grid-cols-2 gap-2 xl:ml-2 xl:flex xl:w-auto xl:shrink-0">
<SearchableSelect
options={LANGUAGES.map((language) => ({
value: language,
label: language,
}))}
placeholder="language"
value={activeTab.language}
onValueChange={setLanguage}
onPreview={setLanguage}
className="w-full xl:w-40"
/>
<SearchableSelect
options={Object.keys(THEME_MAP).map((currentTheme) => ({
value: currentTheme,
label: currentTheme,
}))}
placeholder="theme"
value={theme}
onValueChange={setTheme}
onPreview={setTheme}
className="w-full xl:w-52"
/>
</div>
</div>
</header>
<SearchableSelect
options={Object.keys(THEME_MAP).map((currentTheme) => ({
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 (
<>
<header className="border-border/70 bg-background shrink-0 border-b">
<div className="flex flex-col gap-2 px-4 py-2 sm:flex-row sm:items-center sm:py-3">
<div className="grid w-full grid-cols-6 gap-2 sm:flex sm:w-auto sm:shrink-0">
<Link
className={cn(
buttonVariants({ variant: "outline" }),
"col-span-1 p-2 sm:w-auto",
)}
href="https://github.com/durocodes/spongebin"
>
<Icons.GitHub className="mx-auto h-4 w-4" />
</Link>
<Button
type="button"
variant="outline"
onClick={() => setWordWrap(!wordWrap)}
className={cn(
"col-span-1 p-2 sm:w-auto",
wordWrap &&
"bg-accent text-accent-foreground hover:bg-accent hover:text-accent-foreground",
)}
aria-pressed={wordWrap}
aria-label={wordWrap ? "Disable word wrap" : "Enable word wrap"}
title="Word wrap"
>
<WrapText className="mx-auto h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => (location.href = "/")}
className="col-span-2 sm:w-auto"
>
new
</Button>
<SaveButton
tabs={tabs}
theme={theme}
className="col-span-2 sm:w-auto"
/>
</div>
<div className="w-full min-w-0 sm:flex-1">
<EditorTabs />
</div>
<div className="hidden sm:ml-2 sm:flex sm:w-auto sm:shrink-0 sm:gap-2">
<LanguageThemeControls />
</div>
</div>
</header>
<div
className="fixed bottom-0 left-0 right-0 z-40 grid grid-cols-2 gap-2 border-t border-border/70 bg-background/95 px-4 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] backdrop-blur supports-[backdrop-filter]:bg-background/80 sm:hidden"
role="region"
aria-label="Language and theme"
>
<LanguageThemeControls />
</div>
</>
);
}

View File

@@ -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: {

View File

@@ -54,13 +54,10 @@ export const LANGUAGE_EXTENSIONS: Record<LanguageName, string> = {
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<LanguageName, string>;
@@ -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 = (