|
|
@@ -6,6 +6,7 @@ import {
|
|
|
} from "react";
|
|
|
import {
|
|
|
Text as NativeText,
|
|
|
+ TouchableOpacity,
|
|
|
ScrollView,
|
|
|
Platform,
|
|
|
View
|
|
|
@@ -18,6 +19,8 @@ import stylesheet, {
|
|
|
useStyles
|
|
|
} from "./stylesheet";
|
|
|
import {
|
|
|
+ splitTokensIntoLines,
|
|
|
+ getLineIndentation,
|
|
|
parseCode
|
|
|
} from "./parser";
|
|
|
import {
|
|
|
@@ -27,14 +30,21 @@ import {
|
|
|
} from "../../core/hooks";
|
|
|
import Clipboard from "@react-native-clipboard/clipboard";
|
|
|
import {
|
|
|
+ ChevronRightIcon,
|
|
|
+ ChevronDownIcon,
|
|
|
CheckIcon,
|
|
|
CopyIcon
|
|
|
} from "lucide-react-native";
|
|
|
-
|
|
|
+import {
|
|
|
+ webStyle
|
|
|
+} from "../../utils";
|
|
|
import Button from "../button";
|
|
|
-import Text from "../text";
|
|
|
|
|
|
const CodeViewer = ({
|
|
|
+ isLineNumberVisible = true,
|
|
|
+ autoFoldMaxLines = 60,
|
|
|
+ isCollapsible = true,
|
|
|
+ autoFold = false,
|
|
|
language,
|
|
|
code
|
|
|
}: ICodeViewerProps) => {
|
|
|
@@ -55,77 +65,177 @@ const CodeViewer = ({
|
|
|
|
|
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
|
|
- const containerRef = useRef<import("react").ComponentRef<typeof View>>(null);
|
|
|
|
|
|
- const [
|
|
|
- stickyTop,
|
|
|
- setStickyTop
|
|
|
- ] = useState<number>(0);
|
|
|
|
|
|
useEffect(() => {
|
|
|
- if (Platform.OS !== "web") {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- const handleScroll = (e: Event) => {
|
|
|
- const el = containerRef.current as unknown as HTMLElement;
|
|
|
-
|
|
|
- if (!el || !el.getBoundingClientRect) {
|
|
|
- return;
|
|
|
+ return () => {
|
|
|
+ if (timeoutRef.current) {
|
|
|
+ clearTimeout(timeoutRef.current);
|
|
|
}
|
|
|
+ };
|
|
|
+ }, []);
|
|
|
|
|
|
- const scrollContainer = e.target as Document | HTMLElement;
|
|
|
+ const tokens = useMemo(() => parseCode(code, language), [
|
|
|
+ language,
|
|
|
+ code
|
|
|
+ ]);
|
|
|
|
|
|
- if (scrollContainer !== document && "contains" in scrollContainer && !scrollContainer.contains(el)) {
|
|
|
- return;
|
|
|
- }
|
|
|
+ const lines = useMemo(() => {
|
|
|
+ return splitTokensIntoLines(tokens);
|
|
|
+ }, [tokens]);
|
|
|
|
|
|
- const rect = el.getBoundingClientRect();
|
|
|
- let parentTop = 0;
|
|
|
+ const lineIndentations = useMemo(() => {
|
|
|
+ return lines.map((line) => getLineIndentation(line));
|
|
|
+ }, [lines]);
|
|
|
|
|
|
- if (scrollContainer !== document && "getBoundingClientRect" in scrollContainer) {
|
|
|
- parentTop = scrollContainer.getBoundingClientRect().top;
|
|
|
- }
|
|
|
+ const isLineFoldable = useMemo(() => {
|
|
|
+ return lines.map((_, i) => {
|
|
|
+ const currentIndent = lineIndentations[i] as number;
|
|
|
+ for (let j = i + 1; j < lines.length; j++) {
|
|
|
+ const nextIndent = lineIndentations[j] as number;
|
|
|
+ const nextLineStr = (lines[j] as import("./type").Token[]).map((t) => t.value).join("").trim();
|
|
|
|
|
|
- const offset = parentTop - rect.top;
|
|
|
- const maxOffset = rect.height - (50 + (spaces.spacingMd * 2));
|
|
|
+ if (nextLineStr.length === 0) continue;
|
|
|
|
|
|
- if (offset > 0) {
|
|
|
- setStickyTop(Math.min(offset, maxOffset));
|
|
|
- } else {
|
|
|
- setStickyTop(0);
|
|
|
+ if (nextIndent > currentIndent) {
|
|
|
+ return true;
|
|
|
+ } else {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
}
|
|
|
- };
|
|
|
-
|
|
|
- window.addEventListener("scroll", handleScroll, true);
|
|
|
+ return false;
|
|
|
+ });
|
|
|
+ }, [
|
|
|
+ lines,
|
|
|
+ lineIndentations
|
|
|
+ ]);
|
|
|
|
|
|
- return () => {
|
|
|
- window.removeEventListener("scroll", handleScroll, true);
|
|
|
- };
|
|
|
- }, []);
|
|
|
+ const hasAnyFoldableLine = useMemo(() => {
|
|
|
+ return isLineFoldable.some((foldable) => foldable);
|
|
|
+ }, [isLineFoldable]);
|
|
|
|
|
|
- useEffect(() => {
|
|
|
- return () => {
|
|
|
- if (timeoutRef.current) {
|
|
|
- clearTimeout(timeoutRef.current);
|
|
|
- }
|
|
|
- };
|
|
|
- }, []);
|
|
|
+ const actualIsCollapsible = isCollapsible && hasAnyFoldableLine;
|
|
|
|
|
|
const {
|
|
|
+ languageText: languageTextDynamicStyle,
|
|
|
container: containerDynamicStyle,
|
|
|
+ lineRow: lineRowDynamicStyle,
|
|
|
+ gutter: gutterDynamicStyle,
|
|
|
text: textDynamicStyle
|
|
|
} = useStyles({
|
|
|
+ isLineNumberVisible,
|
|
|
+ isCollapsible: actualIsCollapsible,
|
|
|
typography,
|
|
|
colors,
|
|
|
spaces
|
|
|
});
|
|
|
|
|
|
- const tokens = useMemo(() => parseCode(code, language), [
|
|
|
- language,
|
|
|
- code
|
|
|
+ const initialCollapsedLines = useMemo(() => {
|
|
|
+ const collapsed = new Set<number>();
|
|
|
+ if (!autoFold || !actualIsCollapsible) return collapsed;
|
|
|
+
|
|
|
+ let minIndent = Infinity;
|
|
|
+ for (let i = 0; i < lines.length; i++) {
|
|
|
+ if (isLineFoldable[i]) {
|
|
|
+ const indent = lineIndentations[i] as number;
|
|
|
+ if (indent < minIndent) {
|
|
|
+ minIndent = indent;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (let i = 0; i < lines.length; i++) {
|
|
|
+ if (isLineFoldable[i]) {
|
|
|
+ const startIndent = lineIndentations[i] as number;
|
|
|
+
|
|
|
+ if (startIndent <= minIndent) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ let blockLength = 0;
|
|
|
+
|
|
|
+ for (let j = i + 1; j < lines.length; j++) {
|
|
|
+ const currentIndent = lineIndentations[j] as number;
|
|
|
+ const lineStr = (lines[j] as import("./type").Token[]).map((t) => t.value).join("").trim();
|
|
|
+
|
|
|
+ if (lineStr.length !== 0 && currentIndent <= startIndent) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ blockLength++;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (blockLength > autoFoldMaxLines) {
|
|
|
+ collapsed.add(i);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return collapsed;
|
|
|
+ }, [
|
|
|
+ autoFoldMaxLines,
|
|
|
+ lineIndentations,
|
|
|
+ isLineFoldable,
|
|
|
+ autoFold,
|
|
|
+ lines
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const [
|
|
|
+ collapsedLines,
|
|
|
+ setCollapsedLines
|
|
|
+ ] = useState<Set<number>>(initialCollapsedLines);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ setCollapsedLines(initialCollapsedLines);
|
|
|
+ }, [
|
|
|
+ initialCollapsedLines
|
|
|
]);
|
|
|
|
|
|
+ const visibleLines = useMemo(() => {
|
|
|
+ const visible = new Set<number>();
|
|
|
+ const activeFolds: number[] = [];
|
|
|
+
|
|
|
+ for (let i = 0; i < lines.length; i++) {
|
|
|
+ const indent = lineIndentations[i] as number;
|
|
|
+ const lineStr = (lines[i] as import("./type").Token[]).map((t) => t.value).join("").trim();
|
|
|
+ const isEmpty = lineStr.length === 0;
|
|
|
+
|
|
|
+ if (!isEmpty) {
|
|
|
+ while (activeFolds.length > 0 && indent <= (activeFolds[activeFolds.length - 1] as number)) {
|
|
|
+ activeFolds.pop();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (activeFolds.length > 0) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ visible.add(i);
|
|
|
+
|
|
|
+ if (actualIsCollapsible && collapsedLines.has(i) && isLineFoldable[i]) {
|
|
|
+ activeFolds.push(indent);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return visible;
|
|
|
+ }, [
|
|
|
+ lineIndentations,
|
|
|
+ isLineFoldable,
|
|
|
+ collapsedLines,
|
|
|
+ actualIsCollapsible,
|
|
|
+ lines
|
|
|
+ ]);
|
|
|
+
|
|
|
+ const toggleFold = (lineIndex: number) => {
|
|
|
+ setCollapsedLines((prev) => {
|
|
|
+ const next = new Set(prev);
|
|
|
+ if (next.has(lineIndex)) {
|
|
|
+ next.delete(lineIndex);
|
|
|
+ } else {
|
|
|
+ next.add(lineIndex);
|
|
|
+ }
|
|
|
+ return next;
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
const getTokenColor = (type: TokenType) => {
|
|
|
return colors.code[type as keyof typeof colors.code] || colors.code.default;
|
|
|
};
|
|
|
@@ -176,7 +286,6 @@ const CodeViewer = ({
|
|
|
};
|
|
|
|
|
|
return <View
|
|
|
- ref={containerRef}
|
|
|
style={[
|
|
|
stylesheet.container,
|
|
|
containerDynamicStyle
|
|
|
@@ -193,40 +302,139 @@ const CodeViewer = ({
|
|
|
}
|
|
|
]}
|
|
|
>
|
|
|
- <Text
|
|
|
- style={[
|
|
|
- stylesheet.content,
|
|
|
- textDynamicStyle
|
|
|
- ]}
|
|
|
- >
|
|
|
- {language ? <Text
|
|
|
- color="low"
|
|
|
- >
|
|
|
- // {language}{"\n\n"}
|
|
|
- </Text> : null}
|
|
|
- {tokens.map((token, index) => {
|
|
|
- return <NativeText
|
|
|
- key={`code-token-${index}`}
|
|
|
- style={{
|
|
|
- fontFamily: Platform.OS === "web" ? "monospace" : "System",
|
|
|
- color: getTokenColor(token.type)
|
|
|
- }}
|
|
|
+ <View>
|
|
|
+ {
|
|
|
+ language ?
|
|
|
+ <NativeText
|
|
|
+ selectable={false}
|
|
|
+ style={[
|
|
|
+ stylesheet.content,
|
|
|
+ textDynamicStyle,
|
|
|
+ languageTextDynamicStyle
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ // {language}
|
|
|
+ </NativeText>
|
|
|
+ :
|
|
|
+ null
|
|
|
+ }
|
|
|
+ {lines.map((lineTokens, index) => {
|
|
|
+ if (!visibleLines.has(index)) return null;
|
|
|
+
|
|
|
+ const isFoldable = isLineFoldable[index];
|
|
|
+ const isCollapsed = collapsedLines.has(index);
|
|
|
+ const isGutterVisible = isLineNumberVisible || actualIsCollapsible;
|
|
|
+
|
|
|
+ return <View
|
|
|
+ key={`line-${index}`}
|
|
|
+ style={[
|
|
|
+ stylesheet.lineRow,
|
|
|
+ lineRowDynamicStyle
|
|
|
+ ]}
|
|
|
>
|
|
|
- {token.value}
|
|
|
- </NativeText>;
|
|
|
+ {
|
|
|
+ isGutterVisible ?
|
|
|
+ <View
|
|
|
+ style={[
|
|
|
+ stylesheet.gutter,
|
|
|
+ gutterDynamicStyle,
|
|
|
+ {
|
|
|
+ borderRightColor: colors.content.border.subtle
|
|
|
+ }
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ {
|
|
|
+ isLineNumberVisible ?
|
|
|
+ <NativeText
|
|
|
+ selectable={false}
|
|
|
+ style={[
|
|
|
+ stylesheet.lineNumber,
|
|
|
+ {
|
|
|
+ fontFamily: Platform.OS === "web" ? "monospace" : "System",
|
|
|
+ color: colors.content.text.low
|
|
|
+ }
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ {index + 1}
|
|
|
+ </NativeText>
|
|
|
+ :
|
|
|
+ null
|
|
|
+ }
|
|
|
+ {
|
|
|
+ actualIsCollapsible ?
|
|
|
+ isFoldable ?
|
|
|
+ <TouchableOpacity
|
|
|
+ style={stylesheet.foldIcon}
|
|
|
+ onPress={() => {
|
|
|
+ toggleFold(index);
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {
|
|
|
+ isCollapsed ?
|
|
|
+ <ChevronRightIcon
|
|
|
+ color={colors.content.text.low}
|
|
|
+ size={14}
|
|
|
+ />
|
|
|
+ :
|
|
|
+ <ChevronDownIcon
|
|
|
+ color={colors.content.text.low}
|
|
|
+ size={14}
|
|
|
+ />
|
|
|
+ }
|
|
|
+ </TouchableOpacity>
|
|
|
+ :
|
|
|
+ <View
|
|
|
+ style={stylesheet.foldIconPlaceholder}
|
|
|
+ />
|
|
|
+ :
|
|
|
+ null
|
|
|
+ }
|
|
|
+ </View>
|
|
|
+ :
|
|
|
+ null
|
|
|
+ }
|
|
|
+ <NativeText
|
|
|
+ style={[
|
|
|
+ stylesheet.content,
|
|
|
+ textDynamicStyle,
|
|
|
+ {
|
|
|
+ color: colors.content.text.high
|
|
|
+ }
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ {lineTokens.map((token, tIndex) => {
|
|
|
+ return <NativeText
|
|
|
+ key={`token-${index}-${tIndex}`}
|
|
|
+ style={{
|
|
|
+ color: getTokenColor(token.type)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {token.value}
|
|
|
+ </NativeText>;
|
|
|
+ })}
|
|
|
+ {isCollapsed && (
|
|
|
+ <NativeText
|
|
|
+ style={{
|
|
|
+ backgroundColor: colors.content.container.subtle,
|
|
|
+ color: colors.content.text.mid
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {" ... "}
|
|
|
+ </NativeText>)}
|
|
|
+ </NativeText>
|
|
|
+ </View>;
|
|
|
})}
|
|
|
- </Text>
|
|
|
+ </View>
|
|
|
</ScrollView>
|
|
|
<View
|
|
|
style={[
|
|
|
stylesheet.stickyActionContainer,
|
|
|
{
|
|
|
- transform: [
|
|
|
- {
|
|
|
- translateY: stickyTop
|
|
|
- }
|
|
|
- ],
|
|
|
- marginLeft: spaces.spacingMd
|
|
|
+ marginLeft: spaces.spacingMd,
|
|
|
+ ...webStyle({
|
|
|
+ position: "sticky",
|
|
|
+ top: spaces.spacingMd
|
|
|
+ })
|
|
|
}
|
|
|
]}
|
|
|
>
|