mirror of
https://github.com/SrIzan10/spongebin.git
synced 2026-05-01 11:05:09 +00:00
feat: add word wrapping, make editor tabs behave more like a browser tab bar
This commit is contained in:
@@ -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 }>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user