Skip to content

Commit b44d280

Browse files
committed
feat[frontend]: implemenet an editor
1 parent d4abbda commit b44d280

File tree

11 files changed

+495
-32
lines changed

11 files changed

+495
-32
lines changed

frontend/components/CodeInput.tsx

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// based on vanilla https://css-tricks.com/creating-an-editable-textarea-that-supports-syntax-highlighted-code/
2+
3+
import React, { useEffect, useRef, useState } from "react"
4+
5+
import "../styles/highlight-theme-light.css"
6+
import { tst } from "../utils/overrides.js"
7+
import { escapeHtml } from "../../worker/common.js"
8+
import { usePrism } from "../utils/HighlightLoader.js"
9+
import { Autocomplete, AutocompleteItem, Input, Select, SelectItem } from "@heroui/react"
10+
11+
import "../styles/highlight-theme-light.css"
12+
import "../styles/highlight-theme-dark.css"
13+
14+
// TODO:
15+
// - line number
16+
// - clear button
17+
interface CodeInputProps extends React.HTMLProps<HTMLDivElement> {
18+
content: string
19+
setContent: (code: string) => void
20+
lang?: string
21+
setLang: (lang?: string) => void
22+
filename?: string
23+
setFilename: (filename?: string) => void
24+
placeholder?: string
25+
disabled?: boolean
26+
}
27+
28+
interface TabSetting {
29+
char: "tab" | "space"
30+
width: 2 | 4
31+
}
32+
33+
function formatTabSetting(s: TabSetting) {
34+
return `${s.char} ${s.width}`
35+
}
36+
37+
function parseTabSetting(s: string): TabSetting | undefined {
38+
const match = s.match(/^(tab|space) ([24])$/)
39+
if (match) {
40+
return { char: match[1] as TabSetting["char"], width: parseInt(match[2]) as TabSetting["width"] }
41+
} else {
42+
return undefined
43+
}
44+
}
45+
46+
const tabSettings: TabSetting[] = [
47+
{ char: "tab", width: 2 },
48+
{ char: "tab", width: 4 },
49+
{ char: "space", width: 2 },
50+
{ char: "space", width: 4 },
51+
]
52+
53+
function handleNewLines(str: string): string {
54+
if (str.at(-1) === "\n") {
55+
str += " "
56+
}
57+
return str
58+
}
59+
60+
export function CodeInput({
61+
content,
62+
setContent,
63+
lang,
64+
setLang,
65+
filename,
66+
setFilename,
67+
placeholder,
68+
disabled,
69+
className,
70+
...rest
71+
}: CodeInputProps) {
72+
const refHighlighting = useRef<HTMLPreElement | null>(null)
73+
const refTextarea = useRef<HTMLTextAreaElement | null>(null)
74+
75+
const [heightPx, setHeightPx] = useState<number>(0)
76+
const prism = usePrism()
77+
const [tabSetting, setTabSettings] = useState<TabSetting>({ char: "space", width: 2 })
78+
79+
function syncScroll() {
80+
refHighlighting.current!.scrollLeft = refTextarea.current!.scrollLeft
81+
refHighlighting.current!.scrollTop = refTextarea.current!.scrollTop
82+
}
83+
84+
function handleInput(_: React.FormEvent<HTMLTextAreaElement>) {
85+
const editing = refTextarea.current!
86+
setContent(editing.value)
87+
syncScroll()
88+
}
89+
90+
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
91+
const element = refTextarea.current!
92+
if (event.key === "Tab") {
93+
event.preventDefault() // stop normal
94+
const beforeTab = content.slice(0, element.selectionStart)
95+
const afterTab = content.slice(element.selectionEnd, element.value.length)
96+
const insertedString = tabSetting.char === "tab" ? "\t" : " ".repeat(tabSetting.width)
97+
const curPos = element.selectionStart + insertedString.length
98+
setContent(beforeTab + insertedString + afterTab)
99+
// move cursor
100+
element.selectionStart = curPos
101+
element.selectionEnd = curPos
102+
} else if (event.key === "Escape") {
103+
element.blur()
104+
}
105+
}
106+
107+
useEffect(() => {
108+
setHeightPx(refTextarea.current!.clientHeight)
109+
const observer = new ResizeObserver((entries) => {
110+
for (const entry of entries) {
111+
if (entry.contentRect) {
112+
setHeightPx(entry.contentRect.height)
113+
}
114+
}
115+
})
116+
117+
observer.observe(refTextarea.current!)
118+
119+
return () => {
120+
observer.disconnect()
121+
}
122+
}, [])
123+
124+
function highlightedHTML() {
125+
if (prism && lang && prism.listLanguages().includes(lang) && lang !== "plaintext") {
126+
const highlighted = prism.highlight(handleNewLines(content), { language: lang })
127+
return highlighted.value
128+
} else {
129+
return escapeHtml(content)
130+
}
131+
}
132+
133+
return (
134+
<div className={className} {...rest}>
135+
<div className={"mb-2 gap-4 flex flex-row" + " "}>
136+
<Input type={"text"} label={"File name"} size={"sm"} key={filename} onValueChange={setFilename} />
137+
<Autocomplete
138+
className={"max-w-[10em]"}
139+
label={"Language"}
140+
size={"sm"}
141+
defaultItems={prism ? prism.listLanguages().map((lang) => ({ key: lang })) : []}
142+
selectedKey={lang}
143+
onSelectionChange={(key) => {
144+
setLang((key as string | null) || undefined)
145+
}}
146+
>
147+
{(language) => <AutocompleteItem key={language.key}>{language.key}</AutocompleteItem>}
148+
</Autocomplete>
149+
<Select
150+
size={"sm"}
151+
label={"Tabs"}
152+
className={"max-w-[10em]"}
153+
selectedKeys={[formatTabSetting(tabSetting)]}
154+
onSelectionChange={(s) => {
155+
setTabSettings(parseTabSetting(s.currentKey as string)!)
156+
}}
157+
>
158+
{tabSettings.map((s) => (
159+
<SelectItem key={formatTabSetting(s)}>{formatTabSetting(s)}</SelectItem>
160+
))}
161+
</Select>
162+
</div>
163+
<div className={`w-full bg-default-100 ${tst} rounded-xl p-2`}>
164+
<div className={"relative w-full"}>
165+
<pre
166+
className={"w-full font-mono overflow-auto text-foreground top-0 left-0 absolute"}
167+
ref={refHighlighting}
168+
dangerouslySetInnerHTML={{ __html: highlightedHTML() }}
169+
style={{ height: `${heightPx}px` }}
170+
></pre>
171+
<textarea
172+
className={`w-full font-mono min-h-[20em] text-[transparent] placeholder-default-400 ${tst} caret-foreground bg-transparent outline-none relative`}
173+
ref={refTextarea}
174+
readOnly={disabled}
175+
placeholder={placeholder}
176+
onScroll={syncScroll}
177+
onInput={handleInput}
178+
onKeyDown={handleKeyDown}
179+
value={content}
180+
spellCheck={false}
181+
aria-label={"Paste editor"}
182+
></textarea>
183+
</div>
184+
</div>
185+
</div>
186+
)
187+
}

frontend/components/PasteEditor.tsx

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
import { Card, CardBody, CardProps, mergeClasses, Tab, Tabs, Textarea } from "@heroui/react"
1+
import { Card, CardBody, CardProps, Tab, Tabs } from "@heroui/react"
22
import React, { useRef, useState, DragEvent } from "react"
33
import { formatSize } from "../utils/utils.js"
44
import { XIcon } from "./icons.js"
5-
import { cardOverrides, textAreaOverrides, tst } from "../utils/overrides.js"
5+
import { cardOverrides, tst } from "../utils/overrides.js"
6+
import { CodeInput } from "./CodeInput.js"
67

78
export type EditKind = "edit" | "file"
89

910
export type PasteEditState = {
1011
editKind: EditKind
1112
editContent: string
13+
editFilename?: string
14+
editHighlightLang?: string
1215
file: File | null
1316
}
1417

@@ -43,7 +46,7 @@ export function PasteEditor({ isPasteLoading, state, onStateChange, ...rest }: P
4346

4447
return (
4548
<Card aria-label="Pastebin editor panel" classNames={cardOverrides} {...rest}>
46-
<CardBody>
49+
<CardBody className={"relative"}>
4750
<Tabs
4851
variant="underlined"
4952
classNames={{
@@ -57,23 +60,18 @@ export function PasteEditor({ isPasteLoading, state, onStateChange, ...rest }: P
5760
onStateChange({ ...state, editKind: k as EditKind })
5861
}}
5962
>
60-
<Tab key={"edit"} title="Edit">
61-
<Textarea
62-
isClearable
63+
{/*Possibly a bug of chrome, but Tab sometimes has a transient unexpected scrollbar when resizing*/}
64+
<Tab key={"edit"} title="Edit" className={"overflow-hidden"}>
65+
<CodeInput
66+
content={state.editContent}
67+
setContent={(k) => onStateChange({ ...state, editContent: k })}
68+
lang={state.editHighlightLang}
69+
setLang={(lang) => onStateChange({ ...state, editHighlightLang: lang })}
70+
filename={state.editFilename}
71+
setFilename={(name) => onStateChange({ ...state, editFilename: name })}
72+
disabled={isPasteLoading}
6373
placeholder={isPasteLoading ? "Loading..." : "Edit your paste here"}
64-
isDisabled={isPasteLoading}
65-
className="px-0 py-0"
66-
classNames={mergeClasses(textAreaOverrides, { input: "resize-y min-h-[30rem] font-mono" })}
67-
aria-label="Paste editor"
68-
disableAutosize
69-
disableAnimation
70-
value={state.editContent}
71-
onValueChange={(k) => {
72-
onStateChange({ ...state, editContent: k })
73-
}}
74-
variant="faded"
75-
isRequired
76-
></Textarea>
74+
/>
7775
</Tab>
7876
<Tab key="file" title="File">
7977
<div
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*!
2+
Theme: GitHub Dark
3+
Description: Dark theme as seen on github.com
4+
Author: github.com
5+
Maintainer: @Hirse
6+
Updated: 2021-05-15
7+
8+
Outdated base version: https://github.com/primer/github-syntax-dark
9+
Current colors taken from GitHub's CSS
10+
*/
11+
12+
.dark .hljs {
13+
color: #c9d1d9;
14+
background: #0d1117;
15+
}
16+
17+
.dark .hljs-doctag,
18+
.dark .hljs-keyword,
19+
.dark .hljs-meta .hljs-keyword,
20+
.dark .hljs-template-tag,
21+
.dark .hljs-template-variable,
22+
.dark .hljs-type,
23+
.dark .hljs-variable.language_ {
24+
/* prettylights-syntax-keyword */
25+
color: #ff7b72;
26+
}
27+
28+
.dark .hljs-title,
29+
.dark .hljs-title.class_,
30+
.dark .hljs-title.class_.inherited__,
31+
.dark .hljs-title.function_ {
32+
/* prettylights-syntax-entity */
33+
color: #d2a8ff;
34+
}
35+
36+
.dark .hljs-attr,
37+
.dark .hljs-attribute,
38+
.dark .hljs-literal,
39+
.dark .hljs-meta,
40+
.dark .hljs-number,
41+
.dark .hljs-operator,
42+
.dark .hljs-variable,
43+
.dark .hljs-selector-attr,
44+
.dark .hljs-selector-class,
45+
.dark .hljs-selector-id {
46+
/* prettylights-syntax-constant */
47+
color: #79c0ff;
48+
}
49+
50+
.dark .hljs-regexp,
51+
.dark .hljs-string,
52+
.dark .hljs-meta .hljs-string {
53+
/* prettylights-syntax-string */
54+
color: #a5d6ff;
55+
}
56+
57+
.dark .hljs-built_in,
58+
.dark .hljs-symbol {
59+
/* prettylights-syntax-variable */
60+
color: #ffa657;
61+
}
62+
63+
.dark .hljs-comment,
64+
.dark .hljs-code,
65+
.dark .hljs-formula {
66+
/* prettylights-syntax-comment */
67+
color: #8b949e;
68+
}
69+
70+
.dark .hljs-name,
71+
.dark .hljs-quote,
72+
.dark .hljs-selector-tag,
73+
.dark .hljs-selector-pseudo {
74+
/* prettylights-syntax-entity-tag */
75+
color: #7ee787;
76+
}
77+
78+
.dark .hljs-subst {
79+
/* prettylights-syntax-storage-modifier-import */
80+
color: #c9d1d9;
81+
}
82+
83+
.dark .hljs-section {
84+
/* prettylights-syntax-markup-heading */
85+
color: #1f6feb;
86+
font-weight: bold;
87+
}
88+
89+
.dark .hljs-bullet {
90+
/* prettylights-syntax-markup-list */
91+
color: #f2cc60;
92+
}
93+
94+
.dark .hljs-emphasis {
95+
/* prettylights-syntax-markup-italic */
96+
color: #c9d1d9;
97+
font-style: italic;
98+
}
99+
100+
.dark .hljs-strong {
101+
/* prettylights-syntax-markup-bold */
102+
color: #c9d1d9;
103+
font-weight: bold;
104+
}
105+
106+
.dark .hljs-addition {
107+
/* prettylights-syntax-markup-inserted */
108+
color: #aff5b4;
109+
background-color: #033a16;
110+
}
111+
112+
.dark .hljs-deletion {
113+
/* prettylights-syntax-markup-deleted */
114+
color: #ffdcd7;
115+
background-color: #67060c;
116+
}
117+
118+
.dark .hljs-char.escape_,
119+
.dark .hljs-link,
120+
.dark .hljs-params,
121+
.dark .hljs-property,
122+
.dark .hljs-punctuation,
123+
.dark .hljs-tag {
124+
/* purposely ignored */
125+
}

0 commit comments

Comments
 (0)