|
|
@@ -0,0 +1,531 @@
|
|
|
+import {
|
|
|
+ useState,
|
|
|
+ useRef,
|
|
|
+ type FC
|
|
|
+} from "react";
|
|
|
+import {
|
|
|
+ TouchableOpacity,
|
|
|
+ ScrollView,
|
|
|
+ TextInput,
|
|
|
+ View
|
|
|
+} from "react-native";
|
|
|
+import type {
|
|
|
+ MarkdownEditorMode
|
|
|
+} from "./type";
|
|
|
+import type IMarkdownEditorProps from "./type";
|
|
|
+import stylesheet, {
|
|
|
+ useStyles
|
|
|
+} from "./stylesheet";
|
|
|
+import {
|
|
|
+ NCoreUIKitLocalize,
|
|
|
+ NCoreUIKitTheme
|
|
|
+} from "../../core/hooks";
|
|
|
+import type {
|
|
|
+ TextMarkdownTypes,
|
|
|
+ MarkdownKeys
|
|
|
+} from "../markdownViewer/type";
|
|
|
+import {
|
|
|
+ ChevronsDown as ChevronsDownIcon,
|
|
|
+ ChevronDown as ChevronDownIcon,
|
|
|
+ AlignCenter as AlignCenterIcon,
|
|
|
+ AlignRight as AlignRightIcon,
|
|
|
+ AlignLeft as AlignLeftIcon,
|
|
|
+ Italic as ItalicIcon,
|
|
|
+ Quote as QuoteIcon,
|
|
|
+ Image as ImageIcon,
|
|
|
+ Bold as BoldIcon,
|
|
|
+ Link as LinkIcon,
|
|
|
+ Code as CodeIcon,
|
|
|
+ List as ListIcon
|
|
|
+} from "lucide-react-native";
|
|
|
+import MarkdownViewer from "../markdownViewer";
|
|
|
+import {
|
|
|
+ DEFAULT_VARIANTS
|
|
|
+} from "../markdownViewer/constant";
|
|
|
+import Text from "../text";
|
|
|
+import {
|
|
|
+ webStyle
|
|
|
+} from "../../utils";
|
|
|
+
|
|
|
+const MarkdownEditor: FC<IMarkdownEditorProps> = ({
|
|
|
+ spreadBehaviour = "stretch",
|
|
|
+ isViewerResizable = true,
|
|
|
+ isEditorResizable = true,
|
|
|
+ toolbarContainerStyle,
|
|
|
+ defaultMode = "live",
|
|
|
+ initialValue = "",
|
|
|
+ onChangeText,
|
|
|
+ variants,
|
|
|
+ style,
|
|
|
+ ...props
|
|
|
+}) => {
|
|
|
+ const {
|
|
|
+ radiuses,
|
|
|
+ borders,
|
|
|
+ colors,
|
|
|
+ spaces
|
|
|
+ } = NCoreUIKitTheme.useContext();
|
|
|
+
|
|
|
+ const {
|
|
|
+ localize
|
|
|
+ } = NCoreUIKitLocalize.useContext();
|
|
|
+
|
|
|
+ const [
|
|
|
+ text,
|
|
|
+ setText
|
|
|
+ ] = useState<string>(initialValue);
|
|
|
+
|
|
|
+ const [
|
|
|
+ mode,
|
|
|
+ setMode
|
|
|
+ ] = useState<MarkdownEditorMode>(defaultMode);
|
|
|
+
|
|
|
+ const [
|
|
|
+ selection,
|
|
|
+ setSelection
|
|
|
+ ] = useState<{ end: number; start: number } | null>(null);
|
|
|
+
|
|
|
+ const [
|
|
|
+ isAutoScrollEnabled,
|
|
|
+ setIsAutoScrollEnabled
|
|
|
+ ] = useState<boolean>(true);
|
|
|
+
|
|
|
+ const previewScrollRef = useRef<ScrollView>(null);
|
|
|
+
|
|
|
+ const dynamicStyles = useStyles({
|
|
|
+ spreadBehaviour,
|
|
|
+ radiuses,
|
|
|
+ borders,
|
|
|
+ colors,
|
|
|
+ spaces
|
|
|
+ });
|
|
|
+
|
|
|
+ const handleAction = (prefix: string, suffix: string, placeholder: string) => {
|
|
|
+ let newText = "";
|
|
|
+
|
|
|
+ if (selection && selection.start !== selection.end) {
|
|
|
+ const before = text.substring(0, selection.start);
|
|
|
+ const selected = text.substring(selection.start, selection.end);
|
|
|
+ const after = text.substring(selection.end);
|
|
|
+
|
|
|
+ const hasPrefix = prefix !== "" && selected.startsWith(prefix);
|
|
|
+ const hasSuffix = suffix !== "" && selected.endsWith(suffix);
|
|
|
+
|
|
|
+ const hasOuterPrefix = prefix !== "" && before.endsWith(prefix);
|
|
|
+ const hasOuterSuffix = suffix !== "" && after.startsWith(suffix);
|
|
|
+
|
|
|
+ if (
|
|
|
+ (prefix === "" || hasPrefix) &&
|
|
|
+ (suffix === "" || hasSuffix) &&
|
|
|
+ (prefix !== "" || suffix !== "")
|
|
|
+ ) {
|
|
|
+ const stripped = selected.substring(prefix.length, selected.length - suffix.length);
|
|
|
+ newText = before + stripped + after;
|
|
|
+ } else if (
|
|
|
+ (prefix === "" || hasOuterPrefix) &&
|
|
|
+ (suffix === "" || hasOuterSuffix) &&
|
|
|
+ (prefix !== "" || suffix !== "")
|
|
|
+ ) {
|
|
|
+ const newBefore = before.substring(0, before.length - prefix.length);
|
|
|
+ const newAfter = after.substring(suffix.length);
|
|
|
+ newText = newBefore + selected + newAfter;
|
|
|
+ } else {
|
|
|
+ newText = before + prefix + selected + suffix + after;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ const insertPos = selection ? selection.start : text.length;
|
|
|
+ const before = text.substring(0, insertPos);
|
|
|
+ const after = text.substring(insertPos);
|
|
|
+ newText = before + prefix + placeholder + suffix + after;
|
|
|
+ }
|
|
|
+
|
|
|
+ setText(newText);
|
|
|
+ onChangeText?.(newText);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleTypographySelect = (syntax: string) => {
|
|
|
+ let prefix = syntax;
|
|
|
+ let suffix = "";
|
|
|
+
|
|
|
+ if (syntax === "<p>") {
|
|
|
+ prefix = "<p>";
|
|
|
+ suffix = "</p>";
|
|
|
+ }
|
|
|
+
|
|
|
+ if (selection && selection.start !== selection.end) {
|
|
|
+ const before = text.substring(0, selection.start);
|
|
|
+ const selected = text.substring(selection.start, selection.end);
|
|
|
+ const after = text.substring(selection.end);
|
|
|
+
|
|
|
+ const prependNewline = before.length > 0 && !before.endsWith("\n");
|
|
|
+ const finalPrefix = (prependNewline ? "\n" : "") + prefix;
|
|
|
+
|
|
|
+ const appendNewline = after.length > 0 && !after.startsWith("\n");
|
|
|
+ const finalSuffix = suffix + (appendNewline ? "\n" : "");
|
|
|
+
|
|
|
+ const newText = before + finalPrefix + selected + finalSuffix + after;
|
|
|
+
|
|
|
+ setText(newText);
|
|
|
+ onChangeText?.(newText);
|
|
|
+ } else {
|
|
|
+ const insertPos = selection ? selection.start : text.length;
|
|
|
+ const before = text.substring(0, insertPos);
|
|
|
+ const after = text.substring(insertPos);
|
|
|
+
|
|
|
+ const prependNewline = before.length > 0 && !before.endsWith("\n");
|
|
|
+ const finalPrefix = (prependNewline ? "\n" : "") + prefix;
|
|
|
+
|
|
|
+ const newText = before + finalPrefix + suffix + after;
|
|
|
+
|
|
|
+ setText(newText);
|
|
|
+ onChangeText?.(newText);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const renderToolbar = () => {
|
|
|
+ const activeVariants = {
|
|
|
+ ...DEFAULT_VARIANTS,
|
|
|
+ ...variants
|
|
|
+ };
|
|
|
+
|
|
|
+ const textSyntaxKeys: TextMarkdownTypes[] = [
|
|
|
+ "# ",
|
|
|
+ "## ",
|
|
|
+ "### ",
|
|
|
+ "#### ",
|
|
|
+ "##### ",
|
|
|
+ "###### ",
|
|
|
+ "<p>"
|
|
|
+ ];
|
|
|
+ const typographyKeysToRender = textSyntaxKeys.map(syntax => ({
|
|
|
+ syntax,
|
|
|
+ typographyKey: activeVariants[syntax as MarkdownKeys]
|
|
|
+ })).filter(item => item.typographyKey);
|
|
|
+
|
|
|
+ return <View
|
|
|
+ style={[
|
|
|
+ stylesheet.toolbarContainer,
|
|
|
+ dynamicStyles.toolbarContainer,
|
|
|
+ toolbarContainerStyle
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ <TouchableOpacity
|
|
|
+ onPress={() => handleAction("**", "**", "bold")}
|
|
|
+ style={[
|
|
|
+ stylesheet.toolbarAction,
|
|
|
+ dynamicStyles.toolbarAction
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ <BoldIcon
|
|
|
+ color={colors.content.icon.mid}
|
|
|
+ size={18}
|
|
|
+ />
|
|
|
+ </TouchableOpacity>
|
|
|
+ <TouchableOpacity
|
|
|
+ onPress={() => handleAction("__", "__", "italic")}
|
|
|
+ style={[
|
|
|
+ stylesheet.toolbarAction,
|
|
|
+ dynamicStyles.toolbarAction
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ <ItalicIcon
|
|
|
+ color={colors.content.icon.mid}
|
|
|
+ size={18}
|
|
|
+ />
|
|
|
+ </TouchableOpacity>
|
|
|
+ <View
|
|
|
+ style={[
|
|
|
+ stylesheet.toolbarSeparator,
|
|
|
+ dynamicStyles.toolbarSeparator
|
|
|
+ ]}
|
|
|
+ />
|
|
|
+ <TouchableOpacity
|
|
|
+ onPress={() => handleAction("<link href=\"https://nibgat.com\">\n", "\n</link>", "title")}
|
|
|
+ style={[
|
|
|
+ stylesheet.toolbarAction,
|
|
|
+ dynamicStyles.toolbarAction
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ <LinkIcon
|
|
|
+ color={colors.content.icon.mid}
|
|
|
+ size={18}
|
|
|
+ />
|
|
|
+ </TouchableOpacity>
|
|
|
+ <TouchableOpacity
|
|
|
+ onPress={() => handleAction("[", "](https://nibgat.com/assets/images/logo.png)", "alt")}
|
|
|
+ style={[
|
|
|
+ stylesheet.toolbarAction,
|
|
|
+ dynamicStyles.toolbarAction
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ <ImageIcon
|
|
|
+ color={colors.content.icon.mid}
|
|
|
+ size={18}
|
|
|
+ />
|
|
|
+ </TouchableOpacity>
|
|
|
+ <TouchableOpacity
|
|
|
+ onPress={() => handleAction("```\n", "\n```", "code")}
|
|
|
+ style={[
|
|
|
+ stylesheet.toolbarAction,
|
|
|
+ dynamicStyles.toolbarAction
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ <CodeIcon
|
|
|
+ color={colors.content.icon.mid}
|
|
|
+ size={18}
|
|
|
+ />
|
|
|
+ </TouchableOpacity>
|
|
|
+ <TouchableOpacity
|
|
|
+ onPress={() => handleAction("\n* ", "", "list item")}
|
|
|
+ style={[
|
|
|
+ stylesheet.toolbarAction,
|
|
|
+ dynamicStyles.toolbarAction
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ <ListIcon
|
|
|
+ color={colors.content.icon.mid}
|
|
|
+ size={18}
|
|
|
+ />
|
|
|
+ </TouchableOpacity>
|
|
|
+ <TouchableOpacity
|
|
|
+ onPress={() => handleAction("\n<<\n", "\n>>\n", "quote")}
|
|
|
+ style={[
|
|
|
+ stylesheet.toolbarAction,
|
|
|
+ dynamicStyles.toolbarAction
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ <QuoteIcon
|
|
|
+ color={colors.content.icon.mid}
|
|
|
+ size={18}
|
|
|
+ />
|
|
|
+ </TouchableOpacity>
|
|
|
+ <View
|
|
|
+ style={[
|
|
|
+ stylesheet.toolbarSeparator,
|
|
|
+ dynamicStyles.toolbarSeparator
|
|
|
+ ]}
|
|
|
+ />
|
|
|
+ <TouchableOpacity
|
|
|
+ onPress={() => handleAction("<left>\n", "\n</left>", "content")}
|
|
|
+ style={[
|
|
|
+ stylesheet.toolbarAction,
|
|
|
+ dynamicStyles.toolbarAction
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ <AlignLeftIcon
|
|
|
+ color={colors.content.icon.mid}
|
|
|
+ size={18}
|
|
|
+ />
|
|
|
+ </TouchableOpacity>
|
|
|
+ <TouchableOpacity
|
|
|
+ onPress={() => handleAction("<center>\n", "\n</center>", "content")}
|
|
|
+ style={[
|
|
|
+ stylesheet.toolbarAction,
|
|
|
+ dynamicStyles.toolbarAction
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ <AlignCenterIcon
|
|
|
+ color={colors.content.icon.mid}
|
|
|
+ size={18}
|
|
|
+ />
|
|
|
+ </TouchableOpacity>
|
|
|
+ <TouchableOpacity
|
|
|
+ onPress={() => handleAction("<right>\n", "\n</right>", "content")}
|
|
|
+ style={[
|
|
|
+ stylesheet.toolbarAction,
|
|
|
+ dynamicStyles.toolbarAction
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ <AlignRightIcon
|
|
|
+ color={colors.content.icon.mid}
|
|
|
+ size={18}
|
|
|
+ />
|
|
|
+ </TouchableOpacity>
|
|
|
+ <View
|
|
|
+ style={[
|
|
|
+ stylesheet.toolbarSeparator,
|
|
|
+ dynamicStyles.toolbarSeparator
|
|
|
+ ]}
|
|
|
+ />
|
|
|
+
|
|
|
+ <ScrollView
|
|
|
+ horizontal
|
|
|
+ showsHorizontalScrollIndicator={false}
|
|
|
+ style={stylesheet.typographyScroll}
|
|
|
+ >
|
|
|
+ {typographyKeysToRender.map((item) => {
|
|
|
+ return <TouchableOpacity
|
|
|
+ key={item.typographyKey}
|
|
|
+ style={[
|
|
|
+ stylesheet.typographyPill,
|
|
|
+ dynamicStyles.typographyPill
|
|
|
+ ]}
|
|
|
+ onPress={() => handleTypographySelect(item.syntax)}
|
|
|
+ >
|
|
|
+ <Text
|
|
|
+ color="mid"
|
|
|
+ >
|
|
|
+ {localize(`typography-${item.typographyKey}` as Parameters<typeof localize>[0])}
|
|
|
+ </Text>
|
|
|
+ </TouchableOpacity>;
|
|
|
+ })}
|
|
|
+ </ScrollView>
|
|
|
+ <View
|
|
|
+ style={stylesheet.switchContainer}
|
|
|
+ >
|
|
|
+ <TouchableOpacity
|
|
|
+ onPress={() => setIsAutoScrollEnabled(!isAutoScrollEnabled)}
|
|
|
+ style={[
|
|
|
+ stylesheet.toolbarAction,
|
|
|
+ dynamicStyles.toolbarAction,
|
|
|
+ {
|
|
|
+ backgroundColor: isAutoScrollEnabled ? colors.content.container.primary : "transparent"
|
|
|
+ }
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ {
|
|
|
+ isAutoScrollEnabled ?
|
|
|
+ <ChevronsDownIcon
|
|
|
+ color={colors.content.text.constrastHigh}
|
|
|
+ size={18}
|
|
|
+ />
|
|
|
+ :
|
|
|
+ <ChevronDownIcon
|
|
|
+ color={colors.content.icon.mid}
|
|
|
+ size={18}
|
|
|
+ />
|
|
|
+ }
|
|
|
+ </TouchableOpacity>
|
|
|
+ </View>
|
|
|
+ </View>;
|
|
|
+ };
|
|
|
+
|
|
|
+ return <View
|
|
|
+ style={[
|
|
|
+ stylesheet.container,
|
|
|
+ dynamicStyles.container,
|
|
|
+ style
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ <View
|
|
|
+ style={[
|
|
|
+ stylesheet.tabsContainer,
|
|
|
+ dynamicStyles.tabsContainer
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ <TouchableOpacity
|
|
|
+ style={[
|
|
|
+ stylesheet.tabButton,
|
|
|
+ mode === "live" ? dynamicStyles.tabButtonActive : dynamicStyles.tabButtonInactive
|
|
|
+ ]}
|
|
|
+ onPress={() => setMode("live")}
|
|
|
+ >
|
|
|
+ <Text
|
|
|
+ customColor={mode === "live" ? colors.content.text.emphasized : undefined}
|
|
|
+ color={mode === "live" ? undefined : "mid"}
|
|
|
+ >
|
|
|
+ {localize("live")}
|
|
|
+ </Text>
|
|
|
+ </TouchableOpacity>
|
|
|
+ <TouchableOpacity
|
|
|
+ style={[
|
|
|
+ stylesheet.tabButton,
|
|
|
+ mode === "write" ? dynamicStyles.tabButtonActive : dynamicStyles.tabButtonInactive
|
|
|
+ ]}
|
|
|
+ onPress={() => setMode("write")}
|
|
|
+ >
|
|
|
+ <Text
|
|
|
+ customColor={mode === "write" ? colors.content.text.emphasized : undefined}
|
|
|
+ color={mode === "write" ? undefined : "mid"}
|
|
|
+ >
|
|
|
+ {localize("write")}
|
|
|
+ </Text>
|
|
|
+ </TouchableOpacity>
|
|
|
+ <TouchableOpacity
|
|
|
+ style={[
|
|
|
+ stylesheet.tabButton,
|
|
|
+ mode === "preview" ? dynamicStyles.tabButtonActive : dynamicStyles.tabButtonInactive
|
|
|
+ ]}
|
|
|
+ onPress={() => setMode("preview")}
|
|
|
+ >
|
|
|
+ <Text
|
|
|
+ customColor={mode === "preview" ? colors.content.text.emphasized : undefined}
|
|
|
+ color={mode === "preview" ? undefined : "mid"}
|
|
|
+ >
|
|
|
+ {localize("preview")}
|
|
|
+ </Text>
|
|
|
+ </TouchableOpacity>
|
|
|
+ </View>
|
|
|
+ {
|
|
|
+ (mode === "live" || mode === "preview") &&
|
|
|
+ <View
|
|
|
+ style={[
|
|
|
+ stylesheet.previewContainer,
|
|
|
+ dynamicStyles.previewContainer,
|
|
|
+ mode === "live" ? {
|
|
|
+ marginBottom: 10,
|
|
|
+ minHeight: 100,
|
|
|
+ height: 250,
|
|
|
+ overflow: "hidden",
|
|
|
+ ...(isViewerResizable ? webStyle({
|
|
|
+ resize: "vertical"
|
|
|
+ }) : {})
|
|
|
+ } : {}
|
|
|
+ ]}
|
|
|
+ >
|
|
|
+ {
|
|
|
+ mode === "live" ?
|
|
|
+ <ScrollView
|
|
|
+ ref={previewScrollRef}
|
|
|
+ onContentSizeChange={() => {
|
|
|
+ if (isAutoScrollEnabled) {
|
|
|
+ previewScrollRef.current?.scrollToEnd({
|
|
|
+ animated: true
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <MarkdownViewer
|
|
|
+ variants={variants}
|
|
|
+ content={text}
|
|
|
+ />
|
|
|
+ </ScrollView>
|
|
|
+ :
|
|
|
+ <MarkdownViewer
|
|
|
+ variants={variants}
|
|
|
+ content={text}
|
|
|
+ />
|
|
|
+ }
|
|
|
+ </View>
|
|
|
+ }
|
|
|
+ {
|
|
|
+ (mode === "live" || mode === "write") &&
|
|
|
+ <View>
|
|
|
+ {renderToolbar()}
|
|
|
+ <TextInput
|
|
|
+ {...props}
|
|
|
+ multiline={true}
|
|
|
+ value={text}
|
|
|
+ placeholderTextColor={colors.content.text.mid}
|
|
|
+ onSelectionChange={(e) => {
|
|
|
+ setSelection(e.nativeEvent.selection);
|
|
|
+ props.onSelectionChange?.(e);
|
|
|
+ }}
|
|
|
+ onChangeText={(val) => {
|
|
|
+ setText(val);
|
|
|
+ onChangeText?.(val);
|
|
|
+ }}
|
|
|
+ style={[
|
|
|
+ stylesheet.editorInput,
|
|
|
+ dynamicStyles.editorInput,
|
|
|
+ isEditorResizable ? webStyle({
|
|
|
+ resize: "vertical"
|
|
|
+ }) : {},
|
|
|
+ style
|
|
|
+ ]}
|
|
|
+ />
|
|
|
+ </View>
|
|
|
+ }
|
|
|
+ </View>;
|
|
|
+};
|
|
|
+export default MarkdownEditor;
|