md-editor-lite 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "md-editor-lite-playground",
3
+ "private": true,
4
+ "scripts": {
5
+ "dev": "vite"
6
+ },
7
+ "dependencies": {
8
+ "react": "^19.2.3",
9
+ "react-dom": "^19.2.3"
10
+ },
11
+ "devDependencies": {
12
+ "@vitejs/plugin-react": "^5.1.2"
13
+ }
14
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2020",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+ "jsx": "react-jsx",
17
+
18
+ /* Linting */
19
+ "strict": true,
20
+ "noUnusedLocals": true,
21
+ "noUnusedParameters": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedSideEffectImports": true,
24
+
25
+ "baseUrl": ".",
26
+ "paths": {
27
+ "@/*": ["src/*"]
28
+ }
29
+ },
30
+ "include": ["src"]
31
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2022",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "verbatimModuleSyntax": true,
13
+ "moduleDetection": "force",
14
+ "noEmit": true,
15
+
16
+ /* Linting */
17
+ "strict": true,
18
+ "noUnusedLocals": true,
19
+ "noUnusedParameters": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedSideEffectImports": true
22
+ },
23
+ "include": ["vite.config.ts"]
24
+ }
@@ -0,0 +1,6 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ });
@@ -0,0 +1,21 @@
1
+ import { EditorTab, Toolbar } from "../components";
2
+ import "../css/editor.css";
3
+
4
+ interface EditorHeaderProps {
5
+ mode: "write" | "preview";
6
+ setMode: (mode: "write" | "preview") => void;
7
+ insertMarkdown: (before: string, after?: string) => void;
8
+ }
9
+
10
+ export function EditorHeader({
11
+ mode,
12
+ setMode,
13
+ insertMarkdown,
14
+ }: EditorHeaderProps) {
15
+ return (
16
+ <header className="editor-header">
17
+ <EditorTab mode={mode} setMode={setMode} />
18
+ {mode === "write" && <Toolbar insertMarkdown={insertMarkdown} />}
19
+ </header>
20
+ );
21
+ }
@@ -0,0 +1,27 @@
1
+ import "../css/editor.css";
2
+
3
+ interface EditorTabProps {
4
+ mode: string;
5
+ setMode: (mode: "write" | "preview") => void;
6
+ }
7
+
8
+ export function EditorTab({ mode, setMode }: EditorTabProps) {
9
+ return (
10
+ <div className="editor-tab-container">
11
+ <div
12
+ className={`editor-tab ${mode === "write" ? "editor-tab--active" : ""}`}
13
+ onClick={() => setMode("write")}
14
+ >
15
+ Write
16
+ </div>
17
+ <div
18
+ className={`editor-tab ${
19
+ mode === "preview" ? "editor-tab--active" : ""
20
+ }`}
21
+ onClick={() => setMode("preview")}
22
+ >
23
+ Preview
24
+ </div>
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,39 @@
1
+ import { forwardRef } from "react";
2
+ import "../css/editor.css";
3
+
4
+ interface EditorTextareaProps {
5
+ value: string;
6
+ placeholder?: string;
7
+ setValue: (value: string) => void;
8
+ onImageUpload?: (file: File) => Promise<string>;
9
+ }
10
+
11
+ export const EditorTextarea = forwardRef<
12
+ HTMLTextAreaElement,
13
+ EditorTextareaProps
14
+ >(({ value, placeholder, setValue, onImageUpload }, ref) => {
15
+ const handleDrop = async (e: React.DragEvent) => {
16
+ e.preventDefault();
17
+ if (!onImageUpload) return;
18
+
19
+ const files = Array.from(e.dataTransfer.files).filter((file) =>
20
+ file.type.startsWith("image/")
21
+ );
22
+
23
+ for (const file of files) {
24
+ const url = await onImageUpload(file);
25
+ setValue(value + `\n![${file.name}](${url})\n`);
26
+ }
27
+ };
28
+ return (
29
+ <textarea
30
+ ref={ref}
31
+ value={value}
32
+ onChange={(e) => setValue(e.target.value)}
33
+ onDragOver={(e) => e.preventDefault()}
34
+ onDrop={handleDrop}
35
+ placeholder={placeholder}
36
+ className="editor-textarea"
37
+ ></textarea>
38
+ );
39
+ });
@@ -0,0 +1,90 @@
1
+ import React from "react";
2
+ import { cx } from "../utils/cx";
3
+ import "../css/dropdown.css";
4
+
5
+ export type DropdownOption = {
6
+ value: string;
7
+ label?: string;
8
+ Icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
9
+ };
10
+
11
+ interface IconDropdownProps {
12
+ className?: string;
13
+ disabled?: boolean;
14
+ options: DropdownOption[];
15
+ selected?: boolean;
16
+ triggerIcon: React.ReactNode;
17
+ onChange: (value: string) => void;
18
+ }
19
+
20
+ export function IconDropdown({
21
+ className,
22
+ disabled,
23
+ options,
24
+ selected,
25
+ triggerIcon,
26
+ onChange,
27
+ }: IconDropdownProps) {
28
+ const [isOpen, setIsOpen] = React.useState(false);
29
+ const dropdownRef = React.useRef<HTMLDivElement>(null);
30
+
31
+ const toggle = () => {
32
+ if (!disabled) setIsOpen((prev) => !prev);
33
+ };
34
+
35
+ React.useEffect(() => {
36
+ const handleClickOutside = (e: MouseEvent) => {
37
+ if (
38
+ dropdownRef.current &&
39
+ !dropdownRef.current.contains(e.target as Node)
40
+ ) {
41
+ setIsOpen(false);
42
+ }
43
+ };
44
+ document.addEventListener("mousedown", handleClickOutside);
45
+ return () => document.removeEventListener("mousedown", handleClickOutside);
46
+ }, []);
47
+
48
+ return (
49
+ <div ref={dropdownRef} className={cx("md-dropdown", className)}>
50
+ <button
51
+ type="button"
52
+ onClick={toggle}
53
+ disabled={disabled}
54
+ className={cx(
55
+ "md-dropdown__trigger",
56
+ disabled && "md-dropdown__trigger--disabled"
57
+ )}
58
+ >
59
+ {triggerIcon}
60
+ </button>
61
+
62
+ {isOpen && (
63
+ <ul className="md-dropdown__menu">
64
+ {options.map((option) => (
65
+ <li
66
+ key={option.value}
67
+ className={cx(
68
+ "md-dropdown__item",
69
+ selected && "md-dropdown__item--selected"
70
+ )}
71
+ onClick={() => {
72
+ onChange(option.value);
73
+ setIsOpen(false);
74
+ }}
75
+ >
76
+ {option.Icon && (
77
+ <span className="md-dropdown__icon">
78
+ <option.Icon />
79
+ </span>
80
+ )}
81
+ {option.label && (
82
+ <span className="md-dropdown__label">{option.label}</span>
83
+ )}
84
+ </li>
85
+ ))}
86
+ </ul>
87
+ )}
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,42 @@
1
+ import { useState } from "react";
2
+ import { EditorHeader, EditorTextarea, Preview } from "../components";
3
+ import { useMarkdownEditor } from "../hooks/useMarkdownEditor";
4
+ import "../css/editor.css";
5
+
6
+ interface MarkdownEditorProps {
7
+ value: string;
8
+ onChange: (v: string) => void;
9
+ onImageUpload?: (file: File) => Promise<string>;
10
+ }
11
+
12
+ export function MarkdownEditor({
13
+ value,
14
+ onChange: setValue,
15
+ onImageUpload,
16
+ }: MarkdownEditorProps) {
17
+ const { textareaRef, insertMarkdown } = useMarkdownEditor({
18
+ value,
19
+ setValue,
20
+ });
21
+ const [mode, setMode] = useState<"write" | "preview">("write");
22
+
23
+ return (
24
+ <div className="editor">
25
+ <EditorHeader
26
+ mode={mode}
27
+ setMode={setMode}
28
+ insertMarkdown={insertMarkdown}
29
+ />
30
+ {mode === "write" ? (
31
+ <EditorTextarea
32
+ ref={textareaRef}
33
+ value={value}
34
+ setValue={setValue}
35
+ onImageUpload={onImageUpload}
36
+ />
37
+ ) : (
38
+ <Preview value={value} />
39
+ )}
40
+ </div>
41
+ );
42
+ }
@@ -0,0 +1,16 @@
1
+ import { markdownToHtml } from "../utils/markdown";
2
+ import "../css/preview.css";
3
+
4
+ interface PreviewProps {
5
+ value: string;
6
+ }
7
+
8
+ export function Preview({ value }: PreviewProps) {
9
+ return (
10
+ <div
11
+ className="preview"
12
+ dangerouslySetInnerHTML={{ __html: markdownToHtml(value) }}
13
+ style={{ listStyle: "decimal" }}
14
+ />
15
+ );
16
+ }
@@ -0,0 +1,25 @@
1
+ import { TOOLBAR_BUTTONS } from "../constants/toolbar";
2
+ import { ToolbarDropdownHeading, ToolbarDropdownList } from "../components";
3
+ import "../css/toolbar.css";
4
+
5
+ interface ToolbarProps {
6
+ insertMarkdown: (before: string, after?: string) => void;
7
+ }
8
+
9
+ export function Toolbar({ insertMarkdown }: ToolbarProps) {
10
+ return (
11
+ <menu className="toolbar-menu">
12
+ {TOOLBAR_BUTTONS.map(({ key, before, after, Icon }) => (
13
+ <button
14
+ key={key}
15
+ className="toolbar-button"
16
+ onClick={() => insertMarkdown(before, after)}
17
+ >
18
+ <Icon size={18} />
19
+ </button>
20
+ ))}
21
+ <ToolbarDropdownHeading insertMarkdown={insertMarkdown} />
22
+ <ToolbarDropdownList insertMarkdown={insertMarkdown} />
23
+ </menu>
24
+ );
25
+ }
@@ -0,0 +1,35 @@
1
+ import { IconDropdown } from "../components/IconDropdown";
2
+ import { headingOptions, listOptions } from "../constants/toolbar";
3
+ import { Heading1Icon, ListIcon } from "lucide-react";
4
+
5
+ interface ToolbarDropdownProps {
6
+ insertMarkdown: (before: string, after?: string) => void;
7
+ }
8
+
9
+ export function ToolbarDropdownHeading({
10
+ insertMarkdown,
11
+ }: ToolbarDropdownProps) {
12
+ return (
13
+ <IconDropdown
14
+ options={headingOptions}
15
+ triggerIcon={<Heading1Icon />}
16
+ onChange={(value) => {
17
+ const option = headingOptions.find((o) => o.value === value);
18
+ if (option) insertMarkdown(option.before);
19
+ }}
20
+ />
21
+ );
22
+ }
23
+
24
+ export function ToolbarDropdownList({ insertMarkdown }: ToolbarDropdownProps) {
25
+ return (
26
+ <IconDropdown
27
+ options={listOptions}
28
+ triggerIcon={<ListIcon />}
29
+ onChange={(value) => {
30
+ const option = listOptions.find((o) => o.value === value);
31
+ if (option) insertMarkdown(option.before);
32
+ }}
33
+ />
34
+ );
35
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./EditorHeader";
2
+ export * from "./EditorTab";
3
+ export * from "./EditorTextarea";
4
+ export * from "./MarkdownEditor";
5
+ export * from "./Preview";
6
+ export * from "./Toolbar";
7
+ export * from "./ToolbarDropdown";
@@ -0,0 +1 @@
1
+ export * from "./toolbar";
@@ -0,0 +1,36 @@
1
+ import type { DropdownOption } from "../components/IconDropdown";
2
+ import {
3
+ BoldIcon,
4
+ ItalicIcon,
5
+ CodeIcon,
6
+ LinkIcon,
7
+ Heading1Icon,
8
+ Heading2Icon,
9
+ Heading3Icon,
10
+ ListOrderedIcon,
11
+ ListIcon,
12
+ QuoteIcon,
13
+ } from "lucide-react";
14
+
15
+ type ToolbarDropdownOption = DropdownOption & {
16
+ before: string;
17
+ };
18
+
19
+ export const TOOLBAR_BUTTONS = [
20
+ { key: "bold", before: "**", after: "**", Icon: BoldIcon },
21
+ { key: "italic", before: "*", after: "*", Icon: ItalicIcon },
22
+ { key: "code", before: "`", after: "`", Icon: CodeIcon },
23
+ { key: "blockquote", before: "> ", after: "", Icon: QuoteIcon },
24
+ { key: "link", before: "[", after: "]()", Icon: LinkIcon },
25
+ ];
26
+
27
+ export const headingOptions: ToolbarDropdownOption[] = [
28
+ { value: "h1", Icon: Heading1Icon, before: "# " },
29
+ { value: "h2", Icon: Heading2Icon, before: "## " },
30
+ { value: "h3", Icon: Heading3Icon, before: "### " },
31
+ ];
32
+
33
+ export const listOptions: ToolbarDropdownOption[] = [
34
+ { value: "unordered list", Icon: ListIcon, before: "- " },
35
+ { value: "ordered list", Icon: ListOrderedIcon, before: "1. " },
36
+ ];
@@ -0,0 +1,101 @@
1
+ .md-dropdown {
2
+ position: relative;
3
+ display: inline-flex;
4
+ align-items: center;
5
+ }
6
+
7
+ .md-dropdown__trigger {
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: center;
11
+
12
+ width: 28px;
13
+ height: 28px;
14
+
15
+ background: transparent;
16
+ border: none;
17
+ cursor: pointer;
18
+ transition: background-color 0.15s ease;
19
+ }
20
+
21
+ .md-dropdown__trigger:hover {
22
+ background-color: #f3f4f6;
23
+ }
24
+
25
+ .md-dropdown__trigger--disabled {
26
+ cursor: not-allowed;
27
+ opacity: 0.5;
28
+ }
29
+
30
+ .md-dropdown__menu {
31
+ position: absolute;
32
+ top: 0;
33
+ left: -35%;
34
+ z-index: 50;
35
+ margin-top: 4px;
36
+
37
+ min-width: 40px;
38
+ max-height: 240px;
39
+ overflow-y: auto;
40
+
41
+ background: #ffffff;
42
+ border: 1px solid #e5e7eb;
43
+ border-radius: 8px;
44
+ padding: 4px 0;
45
+
46
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
47
+ }
48
+
49
+ .md-dropdown__item {
50
+ display: flex;
51
+ align-items: center;
52
+ gap: 8px;
53
+
54
+ padding: 10px 12px;
55
+ font-size: 14px;
56
+
57
+ cursor: pointer;
58
+ user-select: none;
59
+ transition: background-color 0.15s ease;
60
+ }
61
+
62
+ .md-dropdown__item:hover {
63
+ background-color: #f3f4f6;
64
+ }
65
+
66
+ .md-dropdown__item--selected {
67
+ background-color: #fefce8;
68
+ color: #a16207;
69
+ font-weight: 500;
70
+ }
71
+
72
+ .md-dropdown__icon {
73
+ display: flex;
74
+ color: #9ca3af;
75
+ }
76
+
77
+ .md-dropdown__label {
78
+ white-space: nowrap;
79
+ }
80
+
81
+ .dropdown-item {
82
+ color: #374151;
83
+ background-color: #f3f4f6;
84
+ position: relative;
85
+ display: flex;
86
+ cursor: pointer;
87
+ align-items: center;
88
+ gap: 0.5rem;
89
+ padding-left: 0.75rem;
90
+ padding-right: 0.75rem;
91
+ padding-top: 0.625rem;
92
+ padding-bottom: 0.625rem;
93
+ font-size: 0.875rem;
94
+ transition: all ease-in-out;
95
+ }
96
+
97
+ .dropdown-item--selected {
98
+ background-color: #fefce8;
99
+ color: #a16207;
100
+ font-weight: 500;
101
+ }
@@ -0,0 +1,67 @@
1
+ .editor {
2
+ display: flex;
3
+ flex-direction: column;
4
+ width: 100%;
5
+ border: 1px solid #e5e7eb;
6
+ border-radius: 8px;
7
+ box-sizing: border-box;
8
+ overflow: hidden;
9
+ }
10
+
11
+ .editor-header {
12
+ display: flex;
13
+ flex-direction: column;
14
+ justify-content: space-evenly;
15
+
16
+ height: 88px;
17
+ padding: 0 16px;
18
+
19
+ background-color: #f9fafb;
20
+ border-bottom: 1px solid #e5e7eb;
21
+ border-top-left-radius: 8px;
22
+ border-top-right-radius: 8px;
23
+ }
24
+
25
+ @media (min-width: 768px) {
26
+ .editor-header {
27
+ flex-direction: row;
28
+ align-items: center;
29
+ justify-content: space-between;
30
+ height: 48px;
31
+ }
32
+ }
33
+
34
+ .editor-tab-container {
35
+ display: flex;
36
+ gap: 1rem;
37
+ font-weight: 500;
38
+ }
39
+
40
+ .editor-tab {
41
+ cursor: pointer;
42
+ padding: 0.25rem 0.5rem;
43
+ font-size: small;
44
+ font-weight: 500;
45
+ transition: color 0.2s;
46
+ color: #4b5563;
47
+
48
+ &:hover {
49
+ color: #1f2937;
50
+ }
51
+ }
52
+
53
+ .editor-tab--active {
54
+ color: #111827;
55
+ border-bottom: 2px solid #111827;
56
+ }
57
+
58
+ .editor-textarea {
59
+ width: 100%;
60
+ min-height: 200px;
61
+ margin: 0;
62
+ padding: 16px;
63
+ border: none;
64
+ resize: none;
65
+ outline: none;
66
+ box-sizing: border-box;
67
+ }
@@ -0,0 +1,11 @@
1
+ .preview {
2
+ color: #111827;
3
+ width: 100%;
4
+ min-height: 200px;
5
+ padding: 16px;
6
+ border: none;
7
+ resize: none;
8
+ outline: none;
9
+ box-sizing: border-box;
10
+ list-style: decimal inside;
11
+ }
@@ -0,0 +1,25 @@
1
+ .toolbar-menu {
2
+ display: flex;
3
+ align-items: center;
4
+ margin: 0;
5
+ padding: 0;
6
+ gap: 16px;
7
+ box-sizing: border-box;
8
+ }
9
+
10
+ .toolbar-button {
11
+ background: none;
12
+ width: 28px;
13
+ height: 28px;
14
+ border: none;
15
+ cursor: pointer;
16
+ transition: background-color 0.2s;
17
+
18
+ &:hover {
19
+ background-color: #f3f4f6;
20
+ }
21
+
22
+ &:active {
23
+ background-color: #e5e7eb;
24
+ }
25
+ }
@@ -0,0 +1 @@
1
+ export * from "./useMarkdownEditor";
@@ -0,0 +1,39 @@
1
+ import { useCallback, useRef } from 'react'
2
+
3
+ interface UseMarkdownEditorProps {
4
+ value: string
5
+ setValue: (v: string) => void
6
+ }
7
+
8
+ export function useMarkdownEditor({ value, setValue }: UseMarkdownEditorProps) {
9
+ const textareaRef = useRef<HTMLTextAreaElement | null>(null)
10
+
11
+ const insertMarkdown = useCallback(
12
+ (before: string, after: string = '') => {
13
+ const textarea = textareaRef.current
14
+ if (!textarea) return
15
+
16
+ const start = textarea.selectionStart
17
+ const end = textarea.selectionEnd
18
+
19
+ const selected = value.slice(start, end)
20
+ const replaced = before + (selected || '') + after
21
+
22
+ setValue(value.slice(0, start) + replaced + value.slice(end))
23
+
24
+ requestAnimationFrame(() => {
25
+ if (!textareaRef.current) return
26
+ const next = start + replaced.length
27
+ textareaRef.current.focus()
28
+ textareaRef.current.selectionStart = next
29
+ textareaRef.current.selectionEnd = next
30
+ })
31
+ },
32
+ [value, setValue]
33
+ )
34
+
35
+ return {
36
+ textareaRef,
37
+ insertMarkdown,
38
+ }
39
+ }