Skip to content

Commit b9331e8

Browse files
authored
feat: copy now includes style information (#41)
* chore: remove debug log from isCaseClauseCompletion function * chore: update button title for copy action to 'Copy All' * chore: add .claude to .gitignore * feat: copy with style Fixes #10 * chore: fix styling
1 parent 3be4eaa commit b9331e8

9 files changed

Lines changed: 228 additions & 74 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,4 @@ functional_tests/features/support/__pycache__
180180
functional_tests/features/steps/helper/__pycache__
181181
html/bundle.js
182182
html/*.wasm
183+
.claude

bun.lock

Lines changed: 19 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/App.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import { useCallback, useMemo, useRef, useState } from "react";
1414
import { useMobileDetection } from "./hooks/useMobileDetection.ts";
1515

1616
export function App() {
17+
const initialCode = "// Start typing your SDL here...\n";
18+
19+
const [code, setCode] = useState<string>(initialCode);
1720
const [toastState, showToast] = useToast();
1821
const { theme, toggleTheme } = useTheme();
1922
const isMobile = useMobileDetection();
@@ -36,14 +39,14 @@ export function App() {
3639
[],
3740
);
3841
const {
39-
code,
40-
setCode,
4142
handleSave,
4243
handleLoad,
4344
handleCopy,
4445
} = useFileOperations({
45-
initialCode: "// Start typing your SDL here...\n",
46+
code,
47+
setCode,
4648
showToast,
49+
editorRef,
4750
});
4851

4952
const lineCount = useMemo(() => code.split("\n").length, [code]);
@@ -111,7 +114,7 @@ export function App() {
111114
<div className="flex-grow overflow-auto">
112115
<Editor
113116
ref={editorRef}
114-
value={code}
117+
code={code}
115118
onCodeChange={setCode}
116119
onCursorChange={onCursorChange}
117120
onParseErrorChange={onParseErrorChange}

src/components/Editor.tsx

Lines changed: 176 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,17 @@ import {
1111
ensureSyntaxTree,
1212
foldAll,
1313
foldGutter,
14+
LanguageSupport,
15+
type TagStyle,
1416
unfoldAll,
1517
} from "@codemirror/language";
16-
import { vscodeDarkInit, vscodeLightInit } from "@uiw/codemirror-theme-vscode";
18+
import { classHighlighter, highlightCode } from "@lezer/highlight";
19+
import {
20+
vscodeDarkInit,
21+
vscodeDarkStyle,
22+
vscodeLightInit,
23+
vscodeLightStyle,
24+
} from "@uiw/codemirror-theme-vscode";
1725
import { EditorView } from "@codemirror/view";
1826
import { SyntacticParseError } from "@mpeggroup/mpeg-sdl-parser";
1927
export { ViewUpdate } from "@codemirror/view";
@@ -22,12 +30,17 @@ import { sdl } from "../sdl/sdlLanguage.ts";
2230
import { sdlLinter } from "../sdl/sdlLinter.ts";
2331
import { ruler } from "../codemirror/ruler.ts";
2432

33+
type TagWithNameAndModified = {
34+
name: string | undefined;
35+
modified: Array<unknown>;
36+
};
37+
2538
const darkTheme = vscodeDarkInit({ settings: { fontSize: "11px" } });
2639
const lightTheme = vscodeLightInit({ settings: { fontSize: "11px" } });
2740

2841
interface EditorProps {
29-
value: string;
30-
onCodeChange: (value: string) => void;
42+
code: string;
43+
onCodeChange: (code: string) => void;
3144
onCursorChange: (position: { line: number; col: number }) => void;
3245
onParseErrorChange: (syntacticParseErrors: SyntacticParseError[]) => void;
3346
theme: "light" | "dark";
@@ -36,55 +49,180 @@ interface EditorProps {
3649
export interface EditorRef {
3750
expandAll: () => void;
3851
collapseAll: () => void;
52+
getStyledCode: () => string;
53+
}
54+
55+
function extractThemeStyleAttributes(themeStyle: TagStyle[]) {
56+
const styleAttributesByTagName = new Map<string, string>();
57+
58+
themeStyle.forEach((style: TagStyle) => {
59+
let attributes = "";
60+
61+
Object.keys(style).forEach((key) => {
62+
if ((key !== "tag") && (key !== "class")) {
63+
const value = style[key as keyof typeof style];
64+
65+
if (typeof value === "string") {
66+
attributes += `${key}: ${value};`;
67+
}
68+
}
69+
});
70+
71+
if (attributes.length === 0) {
72+
return;
73+
}
74+
75+
if (style.tag instanceof Array) {
76+
style.tag.forEach((tag) => {
77+
const actualTag = tag as unknown as TagWithNameAndModified;
78+
79+
if (actualTag.name && (actualTag.modified.length === 0)) {
80+
styleAttributesByTagName.set(actualTag.name, attributes);
81+
}
82+
});
83+
} else {
84+
const actualTag = style.tag as unknown as TagWithNameAndModified;
85+
86+
if (actualTag.name && (actualTag.modified.length === 0)) {
87+
styleAttributesByTagName.set(actualTag.name, attributes);
88+
}
89+
}
90+
});
91+
92+
return styleAttributesByTagName;
93+
}
94+
95+
const lightStyleAttributesByTagName = extractThemeStyleAttributes(
96+
vscodeLightStyle,
97+
);
98+
const darkStyleAttributesByTagName = extractThemeStyleAttributes(
99+
vscodeDarkStyle,
100+
);
101+
102+
function getStyledCode(
103+
sdlLanguageSupport: LanguageSupport,
104+
code: string,
105+
theme: string,
106+
): string {
107+
const styleAttributesByTagName = theme === "dark"
108+
? darkStyleAttributesByTagName
109+
: lightStyleAttributesByTagName;
110+
111+
let richText = "";
112+
113+
function emitSpan(text: string, classes: string | undefined) {
114+
let spanStyleAttributes;
115+
116+
if (classes) {
117+
let tagName = classes;
118+
119+
if (classes?.startsWith("tok-")) {
120+
// If classes starts with "tok-" it is a class added by classHighlighter
121+
tagName = classes.substring(4);
122+
}
123+
124+
const styleAttributes = styleAttributesByTagName.get(tagName);
125+
126+
if (styleAttributes) {
127+
spanStyleAttributes = styleAttributes;
128+
}
129+
}
130+
131+
const textNode = document.createTextNode(text);
132+
const p = document.createElement("p");
133+
134+
p.appendChild(textNode);
135+
136+
let span;
137+
if (spanStyleAttributes) {
138+
span = "<span style='" + spanStyleAttributes + "'>" +
139+
p.innerHTML.replaceAll(" ", "&nbsp;") +
140+
"</span>";
141+
} else {
142+
span = "<span>" + p.innerHTML.replaceAll(" ", "&nbsp;") + "</span>";
143+
}
144+
richText += span;
145+
}
146+
147+
function emitBreak() {
148+
richText += "<br>";
149+
}
150+
151+
highlightCode(
152+
code,
153+
sdlLanguageSupport.language.parser.parse(code),
154+
classHighlighter,
155+
emitSpan,
156+
emitBreak,
157+
);
158+
159+
return `<span style="font-family: monospace">${richText}</span>`;
39160
}
40161

41162
export const Editor = forwardRef<EditorRef, EditorProps>(
42-
({ value, onCodeChange, onCursorChange, onParseErrorChange, theme }, ref) => {
163+
({ code, onCodeChange, onCursorChange, onParseErrorChange, theme }, ref) => {
43164
const lastCursorPosition = useRef({ line: 1, col: 1 });
44165
const editorViewRef = useRef<EditorView | null>(null);
45166

46-
const extensions = useMemo(() => {
47-
const ext = [
48-
codeFolding(),
49-
foldGutter(),
50-
sdl(),
51-
sdlLinter({ onParseErrorChange }),
52-
lintGutter(),
53-
ruler(80),
54-
];
55-
return ext;
56-
}, [onParseErrorChange]);
167+
const sdlLanguageSupport = useMemo(() => sdl(), []);
57168

58-
// Expose methods via ref
59-
useImperativeHandle(ref, () => ({
60-
expandAll: () => {
61-
if (editorViewRef.current) {
62-
const view = editorViewRef.current;
63-
const state = view.state;
169+
// Memoize static extensions that never change
170+
const staticExtensions = useMemo(() => [
171+
codeFolding(),
172+
foldGutter(),
173+
lintGutter(),
174+
ruler(80),
175+
], []);
64176

65-
view.dispatch({});
177+
// Memoize dynamic extensions that depend on props
178+
const dynamicExtensions = useMemo(() => [
179+
sdlLinter({ onParseErrorChange }),
180+
], [onParseErrorChange]);
66181

67-
ensureSyntaxTree(view.state, state.doc.length, 5000);
182+
const extensions = useMemo(() => [
183+
...staticExtensions,
184+
sdlLanguageSupport,
185+
...dynamicExtensions,
186+
], [staticExtensions, sdlLanguageSupport, dynamicExtensions]);
68187

69-
unfoldAll(view);
70-
}
71-
},
72-
collapseAll: () => {
73-
if (editorViewRef.current) {
74-
const view = editorViewRef.current;
75-
const state = view.state;
188+
// Memoize imperative methods
189+
const expandAll = useCallback(() => {
190+
if (editorViewRef.current) {
191+
const view = editorViewRef.current;
192+
const state = view.state;
76193

77-
view.dispatch({});
194+
view.dispatch({});
78195

79-
ensureSyntaxTree(view.state, state.doc.length, 5000);
196+
ensureSyntaxTree(view.state, state.doc.length, 5000);
80197

81-
foldAll(view);
82-
}
198+
unfoldAll(view);
199+
}
200+
}, []);
201+
202+
const collapseAll = useCallback(() => {
203+
if (editorViewRef.current) {
204+
const view = editorViewRef.current;
205+
const state = view.state;
206+
207+
view.dispatch({});
208+
209+
ensureSyntaxTree(view.state, state.doc.length, 5000);
210+
211+
foldAll(view);
212+
}
213+
}, []);
214+
215+
// Expose methods via ref
216+
useImperativeHandle(ref, () => ({
217+
expandAll,
218+
collapseAll,
219+
getStyledCode: () => {
220+
return getStyledCode(sdlLanguageSupport, code, theme);
83221
},
84-
}), []);
222+
}), [expandAll, collapseAll, sdlLanguageSupport, code, theme]);
85223

86-
const onInternalCodeChange = useCallback((val: string) => {
87-
onCodeChange(val);
224+
const onInternalCodeChange = useCallback((code: string) => {
225+
onCodeChange(code);
88226
}, [onCodeChange]);
89227

90228
const onViewUpdate = useCallback((viewUpdate: ViewUpdate) => {
@@ -114,7 +252,7 @@ export const Editor = forwardRef<EditorRef, EditorProps>(
114252
return (
115253
<div className="h-full w-full">
116254
<CodeMirror
117-
value={value}
255+
value={code}
118256
theme={theme === "dark" ? darkTheme : lightTheme}
119257
width="100%"
120258
height="100%"

src/components/Navbar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export function Navbar({
5151
<button
5252
type="button"
5353
onClick={onCopy}
54-
title="Copy"
54+
title="Copy All"
5555
className="[&_svg]:w-6 [&_svg]:h-6"
5656
>
5757
<svg

0 commit comments

Comments
 (0)