Compare commits

..

310 Commits

Author SHA1 Message Date
627d98c199 Update README with new AI features and enhancements 2026-03-14 00:35:21 +01:00
5db45375f3 Add sanitizeAiResponse function call in App.tsx 2026-03-14 00:34:24 +01:00
cb814d94fa Add function to sanitize AI response 2026-03-14 00:33:21 +01:00
4393a40ad2 Add AI action dispatch and scope handling in App.tsx 2026-03-14 00:29:20 +01:00
7e854bd5d1 Remove unused AI action handling functions 2026-03-14 00:29:16 +01:00
1c664523a6 Add styles for modal selection preview 2026-03-14 00:15:03 +01:00
ebaa428f05 Update AI edit scope modal and custom prompt handling 2026-03-14 00:14:52 +01:00
97e9b26251 Update keydown event handling in src/App.tsx 2026-03-14 00:14:43 +01:00
73beaea077 Remove unused function openCustomPromptLightbox in src/App.tsx 2026-03-14 00:14:43 +01:00
9a42468cfd Update custom prompt handling in App.tsx 2026-03-14 00:14:29 +01:00
2b0023d3e1 Add AI action dispatch and scope handling in App.tsx 2026-03-14 00:14:22 +01:00
790848342c Enhance AI action handling with scope and selection support 2026-03-14 00:14:12 +01:00
f4e79276f0 Refactor state management in App.tsx 2026-03-14 00:13:53 +01:00
ef1201f753 Add types for AI action request and related components 2026-03-14 00:13:47 +01:00
d128ac92dc Add custom prompt feature and enhance keyboard handling 2026-03-14 00:06:44 +01:00
4432251158 Add custom prompt handling and update AI tools menu 2026-03-14 00:06:34 +01:00
79cd72f24f Add openCustomPromptLightbox function 2026-03-14 00:06:34 +01:00
727ef480c5 Add custom prompt state and handlers 2026-03-14 00:06:34 +01:00
2c20ab748e Add persistCurrentTextVersion function and update runAiAction to use it 2026-03-13 23:32:55 +01:00
cd2c4d3782 Remove unused variable in App.tsx 2026-03-13 23:14:20 +01:00
507a6f9f9a Update CSS for settings panel and prompt accordion 2026-03-13 23:09:31 +01:00
ee36277c21 Simplify and optimize AI prompt template deletion logic 2026-03-13 23:09:24 +01:00
f7a5c21d52 Ensure expandedPromptId is valid when aiPromptTemplates change 2026-03-13 23:09:19 +01:00
e5e24eb7f3 Reset expandedPromptId default value in App.tsx 2026-03-13 23:09:13 +01:00
07cfb06ee6 Update button disabled condition in App.tsx 2026-03-13 23:09:13 +01:00
6d414d1592 Refactor prompt management in App.tsx 2026-03-13 23:09:02 +01:00
f255f9a5a1 Simplify runAiAction and update AI tool handling 2026-03-13 23:08:47 +01:00
7df435d18d Refactor AI prompt handling in App.tsx 2026-03-13 23:08:19 +01:00
ffb83f9c71 Add function to get AI prompt template label and update state management 2026-03-13 23:08:12 +01:00
a4e863751b Refactor AI prompt handling and storage 2026-03-13 23:08:05 +01:00
e2b739ec6b Update settings panel width and add new styles 2026-03-13 22:58:31 +01:00
18f8ccc62a Refactor prompt section in App.tsx 2026-03-13 22:58:23 +01:00
625be7503d Update button click handler and label in App.tsx 2026-03-13 22:58:23 +01:00
4da563df70 Refactor AI action handling and add AI tools menu 2026-03-13 22:58:02 +01:00
f0765c1f42 Add functions to update AI prompt and add style preset 2026-03-13 22:57:01 +01:00
43e1fb1c79 Update App.tsx with new state variables and localStorage handling 2026-03-13 22:56:50 +01:00
b211342e8a Add AI prompt definitions and related functionality 2026-03-13 22:56:45 +01:00
f5f1b76bfb Enhance database export functionality and update CSS styles 2026-03-13 21:57:48 +01:00
d5bfefefd1 Refactor settings panel layout and add export database functionality 2026-03-13 21:57:42 +01:00
a7d5b58dbf Implement database export functionality 2026-03-13 21:57:08 +01:00
6fa837ab61 Reset database export status when settings are closed 2026-03-13 21:57:08 +01:00
a3947147d6 Add database export state variables 2026-03-13 21:57:08 +01:00
f9bea7ecaa Update App.tsx to include database export functionality 2026-03-13 21:56:44 +01:00
b40c8eae06 Add function to export database snapshot 2026-03-13 21:56:44 +01:00
14e1f40ecf Add logic to focus editor after creating new text 2026-03-13 21:54:19 +01:00
b2b0afdaf6 Add --md-surface-alt variable to markdown-render.css 2026-03-12 16:17:00 +01:00
f1c96d66e6 Add light theme styles for editor textarea wrap and markdown preview 2026-03-12 16:15:54 +01:00
e92af02673 Update markdown root surface alt color for dark theme 2026-03-12 16:14:42 +01:00
db20dd5107 Add background styling for dark theme in editor and markdown preview 2026-03-12 16:14:42 +01:00
56ab395eb2 Update CSS for dark theme in editor components 2026-03-12 16:12:33 +01:00
7671d6e39a Update editor theme extension in App.tsx 2026-03-12 16:00:54 +01:00
c8c7814db4 Update editor theme handling and caret color in CSS 2026-03-12 15:59:31 +01:00
7bbdab340c Add editor theme extension based on theme in App.tsx 2026-03-12 15:59:25 +01:00
41393e59bb Add themeCompartmentRef to App.tsx 2026-03-12 15:59:25 +01:00
fbd573d9e5 auto-git:
[change] src/index.css
2026-03-12 15:56:00 +01:00
30727b4bee auto-git:
[change] src/App.tsx
2026-03-11 11:08:51 +01:00
c7b7c40137 Update dependencies and add search functionality 2026-03-11 11:08:51 +01:00
e0e86b9199 auto-git:
[change] .gitignore
2026-03-11 10:29:11 +01:00
7d8d7f6110 promptDB gelöscht 2026-02-01 06:26:21 +01:00
b7850c270a Refactor main.rs in src-tauri/src/main.rs 2026-02-01 06:20:43 +01:00
a2eb529fcf Add promptDB file 2026-02-01 06:20:43 +01:00
ff5c3073dc Update README.md: remove Ollama link, update AI setup instructions 2026-02-01 05:25:15 +01:00
c4ad259362 Update README.md: clarify Markdown conversion options 2026-02-01 05:25:15 +01:00
9b634ccc05 Clarify no outside network calls in README 2026-02-01 05:25:05 +01:00
cb66d08027 Add Ollama link to README 2026-02-01 05:25:05 +01:00
98b01098a1 Add local AI conversion feature to README 2026-02-01 05:25:05 +01:00
63efadd3fb Add functionality to convert text to Markdown using Ollama API 2026-02-01 05:15:30 +01:00
af01951d9a Remove unused function handleConvertToMarkdown in src/App.tsx 2026-02-01 05:14:46 +01:00
2b4b86816c Update model selection logic in App.tsx 2026-02-01 05:11:08 +01:00
9232054331 Update URL normalization logic in App.tsx 2026-02-01 05:10:49 +01:00
c9edc1e266 Remove unused dependency in App.tsx 2026-02-01 05:10:49 +01:00
a16a4ef0f9 Add styles for settings panel elements 2026-02-01 05:10:15 +01:00
621c7a3681 Add Ollama settings section in App.tsx 2026-02-01 05:10:08 +01:00
4266708e8b Add 'Convert to Markdown' button in App.tsx 2026-02-01 05:10:07 +01:00
57cff46ccd Add Ollama model loading and text conversion functionality 2026-02-01 05:09:49 +01:00
1215ab8fae Add Ollama integration and state management in App.tsx 2026-02-01 05:09:41 +01:00
d9ee24e7cb Update delete logic for text and folders 2026-02-01 04:39:59 +01:00
3a30d6799e Refactor delete text and folder actions in App.tsx 2026-02-01 04:39:52 +01:00
a47eab7a03 Add quick delete functionality for texts and folders 2026-02-01 04:39:44 +01:00
a433510f05 Add folder child count and empty check logic in App.tsx 2026-02-01 04:39:28 +01:00
4413a57c0d Update theme logic in App.tsx 2026-02-01 04:36:11 +01:00
7f2fe4a823 Change option values for theme selection in App.tsx 2026-02-01 04:35:11 +01:00
497738eb73 Update theme state type and logic in App.tsx 2026-02-01 04:35:10 +01:00
3f026499c0 Update state initialization logic in App.tsx 2026-02-01 04:34:44 +01:00
80f928a1f7 Add CSS for print-markdown class 2026-02-01 04:31:39 +01:00
742eecb873 Update editor configuration to consider splitView 2026-02-01 04:31:39 +01:00
9bf4cf1866 Add split editor layout styles 2026-02-01 04:29:48 +01:00
864b3f4c88 Add split view toggle to settings panel in App.tsx 2026-02-01 04:29:43 +01:00
7bee133a9c Update App.tsx to handle markdown preview and split view states 2026-02-01 04:29:38 +01:00
fefa100175 Include splitView in the dependency array for requestMeasure 2026-02-01 04:29:31 +01:00
cf92325bed Add split view state and update localStorage handling 2026-02-01 04:29:31 +01:00
44e64df922 Update README.md: Remove toggle instruction from Markdown preview mode description 2026-02-01 04:27:22 +01:00
7e2964e9f9 Remove unused import in src/App.tsx 2026-02-01 04:25:16 +01:00
2f84b8bce1 Remove unused tab keymap functionality in App.tsx 2026-02-01 04:24:10 +01:00
485e8b4ed8 Reorder imports in App.tsx for consistency 2026-02-01 04:19:24 +01:00
eecba280a3 Remove unused dependency in package.json 2026-02-01 04:19:24 +01:00
4cc25c1f87 Remove unused CSS import in App.tsx 2026-02-01 04:16:03 +01:00
f4baeeca3d Update keymap configuration in App.tsx 2026-02-01 04:12:15 +01:00
2265ae70b5 Add editorReady state and update dependencies 2026-02-01 04:10:26 +01:00
18945e578e Change font family to inherit in index.css 2026-02-01 04:10:26 +01:00
27ca21094d Prevent default behavior for save shortcut in editor 2026-02-01 04:10:26 +01:00
9671f63828 Refactor editor setup and add tab keymap functionality 2026-02-01 04:09:24 +01:00
2f459a0df4 Hide line numbers and measure in print mode 2026-02-01 04:04:27 +01:00
fd734a7c64 Import CodeMirror styles in App.tsx 2026-02-01 04:03:20 +01:00
7518f032a2 Remove unused CSS for line numbers 2026-02-01 04:02:04 +01:00
3456700ccd Add effect to request editor measure on state changes 2026-02-01 04:01:56 +01:00
f0b7a49611 Change font size in index.css to use CSS variable 2026-02-01 04:01:56 +01:00
9f962afcb5 Update package.json with new dependencies 2026-02-01 04:01:56 +01:00
0413d87a55 Remove src/lib/tln.ts file 2026-02-01 04:01:04 +01:00
aeafaf1d96 Update editor styles for CodeMirror integration 2026-02-01 04:00:57 +01:00
7921384ddf Replace textarea with div for CodeMirror in App.tsx 2026-02-01 04:00:33 +01:00
b843b72670 Update App.tsx to use editorView and compartments for line numbers and editable state 2026-02-01 04:00:21 +01:00
14ed5afe7b Add useEffect to initialize CodeMirror editor in App.tsx 2026-02-01 03:59:56 +01:00
34d8434fb3 Update App.tsx with new refs for editor components 2026-02-01 03:59:56 +01:00
9ecbe6ec65 Update App.tsx with CodeMirror imports and configurations 2026-02-01 03:59:44 +01:00
3ccb7bbe68 Set height to 100% for folder-item in index.css 2026-02-01 03:53:02 +01:00
28809839f2 Use markdownHtml for dangerouslySetInnerHTML in App.tsx 2026-02-01 03:53:02 +01:00
97a3871a8d Add markdownHtml memoization in App.tsx 2026-02-01 03:53:02 +01:00
8c1cf0e6a0 Add useEffect to refresh line numbers in App.tsx 2026-02-01 03:51:30 +01:00
6c528d6fb0 Import refreshLineNumbers in App.tsx 2026-02-01 03:51:30 +01:00
81a7a4fac8 Add refreshLineNumbers function to tln.ts 2026-02-01 03:51:30 +01:00
5b5371d3a8 Add CSS for hidden textarea and line numbers 2026-02-01 03:51:03 +01:00
a1f129a9cb No actual change detected in this commit 2026-02-01 03:51:03 +01:00
44b00e4a13 Import and use appendLineNumbers and removeLineNumbers functions 2026-02-01 03:51:03 +01:00
9dbce4ebee Add line number functionality to text area 2026-02-01 03:51:03 +01:00
18ae1c3d3b Remove onScroll event listener from textarea 2026-02-01 03:51:03 +01:00
15a1b22c91 Remove unused handleTextareaScroll function 2026-02-01 03:51:03 +01:00
afe8a39085 Simplify and optimize App.tsx by removing line numbers and improving markdown preview handling 2026-02-01 03:49:47 +01:00
ddba57d28b Refactor line number handling in App.tsx 2026-02-01 03:49:27 +01:00
10c6043e4e Simplify handleTextareaScroll and remove unused memoized functions in App.tsx 2026-02-01 03:49:09 +01:00
574e7899a5 Remove unused state and ref variables in App.tsx 2026-02-01 03:49:09 +01:00
2d256af21f Remove unused CSS classes in src/index.css 2026-02-01 03:32:32 +01:00
3ed9de1c54 Remove line number measure element from App.tsx 2026-02-01 03:32:27 +01:00
dd288313b9 Simplify line measurement logic in App.tsx 2026-02-01 03:32:27 +01:00
699b55b8fd Remove unused measureRef from App.tsx 2026-02-01 03:32:27 +01:00
997c8767e6 Optimize line metrics computation in App.tsx 2026-02-01 03:32:11 +01:00
b5a6b556c9 Update visible range calculation in App.tsx 2026-02-01 03:32:11 +01:00
51e8fea77b Refactor line calculation logic in App.tsx 2026-02-01 03:31:21 +01:00
7e170dfe85 Update refs and remove unused variables in App.tsx 2026-02-01 03:31:21 +01:00
78758331bb Remove unused FenwickTree class from src/App.tsx 2026-02-01 03:30:53 +01:00
2ea212fd53 Add relative positioning and adjust line numbers in index.css 2026-02-01 03:29:02 +01:00
b907855513 Refactor line number rendering in App.tsx 2026-02-01 03:28:53 +01:00
0024acdf40 Update line height calculation and management in App.tsx 2026-02-01 03:28:40 +01:00
9936fd2c0f Optimize scroll handling in App.tsx 2026-02-01 03:28:09 +01:00
1c9af949b4 Implement line number calculation and visibility tracking in App.tsx 2026-02-01 03:28:05 +01:00
3a2569daad Add state variables for line height and visible range in App.tsx 2026-02-01 03:28:05 +01:00
28edef621c Add FenwickTree class to src/App.tsx 2026-02-01 03:27:23 +01:00
e1e223ed55 Update README.md with improved feature descriptions 2026-02-01 03:19:51 +01:00
ec5cf906d2 Simplify and update README.md features section 2026-02-01 03:18:23 +01:00
016c2a3d4e Update README.md with new folder deletion behavior 2026-02-01 03:18:23 +01:00
6701d7fed9 Swap functionality of new folder and open text buttons in App.tsx 2026-02-01 03:15:19 +01:00
5b93cf2916 Replace icon glyphs with images in App.tsx 2026-02-01 03:14:09 +01:00
a7d69afc8b Update icon imports and add new icons in App.tsx 2026-02-01 03:13:27 +01:00
03621a0f91 Rename new_file_b.png to new_text_b.png in icons directory 2026-02-01 03:13:00 +01:00
db0c456543 Rename settins.png to settings.png 2026-02-01 03:11:50 +01:00
de0ad07fc9 Rename open.png to open_file.png 2026-02-01 03:11:50 +01:00
891cf63261 Remove Unbenannt-1.png icon 2026-02-01 03:11:50 +01:00
3ed4c36ee9 Update new_file_b.png icon 2026-02-01 03:11:50 +01:00
c8545e9670 Add Unbenannt-1.png icon 2026-02-01 03:11:50 +01:00
dc1a515a2a Update open_file_b.png icon 2026-02-01 03:11:50 +01:00
5f16ebe51e Add sidebar expand icon 2026-02-01 03:10:25 +01:00
3466e1901b Add sidebar collapse icon 2026-02-01 03:10:25 +01:00
733378d10f Add settings icon 2026-02-01 03:10:25 +01:00
0863282fdf Add new icons for file operations 2026-02-01 03:10:25 +01:00
02449dd491 Add new_folder icon 2026-02-01 03:09:13 +01:00
bc5dd85b3a Add new_text icon 2026-02-01 03:09:13 +01:00
5bbe9f65b7 Add open icon 2026-02-01 03:09:13 +01:00
b2c139fe18 Add settins icon 2026-02-01 03:09:13 +01:00
31e0f19487 Add sb_collapse icon 2026-02-01 03:09:13 +01:00
9a26725c30 Add sb_expand icon 2026-02-01 03:09:13 +01:00
5c82bb9f89 Add new text button and expand sidebar functionality in App.tsx 2026-02-01 02:53:48 +01:00
379ea3b653 Simplify sidebar layout in index.css 2026-02-01 02:53:48 +01:00
3ef4b7e514 Refactor sidebar layout and button functionality in App.tsx 2026-02-01 02:53:29 +01:00
2d39935045 Update grid layout and remove unused CSS 2026-02-01 02:50:19 +01:00
25561dd7f8 Refactor sidebar layout and button handling in App.tsx 2026-02-01 02:50:16 +01:00
f75fdf8e41 Update CSS for workspace layout and footer styling 2026-02-01 02:47:10 +01:00
10812ee712 Adjust padding in index.css for layout consistency 2026-02-01 02:47:03 +01:00
fe9910a220 Reorganize workspace layout and add new text button styling 2026-02-01 02:46:39 +01:00
1fcef8864f Adjust sidebar footer alignment and padding 2026-02-01 02:43:48 +01:00
ecf860c3a7 Update sidebar collapse button styling and structure 2026-02-01 02:43:48 +01:00
23af6343ca Update sidebar footer layout and button styles 2026-02-01 02:40:54 +01:00
85ed81de10 Refactor sidebar buttons in App.tsx 2026-02-01 02:40:52 +01:00
bc350b5fe9 Adjust top position of close button in index.css 2026-02-01 02:11:20 +01:00
1d2f45af5b Move folder expansion logic to moveTextToFolder callback 2026-02-01 02:08:44 +01:00
c66fee4541 Ensure folder children are only displayed if they exist 2026-02-01 02:08:41 +01:00
30d03e20c1 Remove unused CSS for folder item hover effect 2026-02-01 02:07:11 +01:00
9b88e9306c Remove transition effects in index.css 2026-02-01 02:05:54 +01:00
0b0d3daddc Update border color in index.css 2026-02-01 02:05:54 +01:00
3d1857d290 Further adjust folder item transformation 2026-02-01 02:03:39 +01:00
7a8324b22f Adjust folder item transformation and icon dimensions 2026-02-01 02:03:39 +01:00
0b140b19b7 Increase font size for folder item text 2026-02-01 02:02:16 +01:00
3a48a0a38d Adjust margin for folder item text 2026-02-01 02:02:16 +01:00
d8db15f958 Reduce size of folder item toggle 2026-02-01 02:02:16 +01:00
13b10dccef Adjust gap in folder item lead 2026-02-01 02:00:39 +01:00
d7b2d19759 Update gap in folder item and add lead class 2026-02-01 02:00:39 +01:00
dee929998c Wrap folder toggle and icon in a div 2026-02-01 02:00:22 +01:00
7c6e14a71b Add margin-left to .folder-item 2026-02-01 02:00:22 +01:00
975190efd0 Remove unused styles and add folder item icon styling 2026-02-01 01:58:57 +01:00
e4aa205ab2 Add folder icon and update component structure 2026-02-01 01:58:57 +01:00
12088163dc Update folder rendering to handle entries by parent 2026-02-01 01:55:58 +01:00
d78911d17e Refactor sidebar entry types and sorting logic in App.tsx 2026-02-01 01:55:54 +01:00
4d61e4d5cc Update createText function call to remove sortOrder parameter 2026-02-01 01:51:56 +01:00
3970a30f59 Remove unused parameters in move functions 2026-02-01 01:51:56 +01:00
4baeb10e6d Remove unused variables in App.tsx 2026-02-01 01:50:46 +01:00
bdb67d4093 Remove unused functions getNextTextSortOrder and getNextFolderSortOrder in src/App.tsx 2026-02-01 01:50:39 +01:00
45d7cd940d Simplify sorting logic in App.tsx 2026-02-01 01:50:32 +01:00
9e07db5a2d Remove sort_order columns from db schema and related functions 2026-02-01 01:49:55 +01:00
710eb65f46 Add folder_b icon to src-tauri/icons 2026-02-01 01:42:43 +01:00
0e8085a98b Add folder icon to src-tauri/icons 2026-02-01 01:42:43 +01:00
196cdd1764 Update folder and prompt deletion to include timestamp updates 2026-02-01 01:40:19 +01:00
efdb3cc710 Add CSS for first child folder item positioning 2026-02-01 01:39:16 +01:00
4ae3a0282e Add dark theme support for prompt and folder items in index.css 2026-02-01 01:38:11 +01:00
c31bb9ec65 Update border and line styles in index.css 2026-02-01 01:36:41 +01:00
80654dc515 Refactor folder item styling in index.css 2026-02-01 01:33:04 +01:00
72715d0b9c Refactor folder and prompt item styling in index.css 2026-02-01 01:32:25 +01:00
d7bc9a4358 Adjust left position in folder children styling 2026-02-01 01:32:25 +01:00
077f9df03e Adjust left position of folder children pseudo-elements 2026-02-01 01:29:46 +01:00
5a92648a7d Update tree line styles in index.css 2026-02-01 01:29:46 +01:00
5fa861b315 Add --tree-line variable to CSS 2026-02-01 01:29:46 +01:00
7a7ab7f067 Update folder children styling 2026-02-01 01:27:19 +01:00
4da9032467 Increase border width and height in index.css 2026-02-01 01:26:36 +01:00
14fe9f9f2b Add folder children separator and adjust padding 2026-02-01 01:16:53 +01:00
663d7750ba Adjust padding and dimensions in folder item styling 2026-02-01 01:15:34 +01:00
1d9c1246dd Update folder item styles and add delete button 2026-02-01 01:09:58 +01:00
4ae8f6dfc2 Add delete button for folder items 2026-02-01 01:09:52 +01:00
bc68d4b661 Add folder move functionality and update context menu 2026-02-01 01:06:51 +01:00
58184c0f51 Remove unused parameter in renderTextItem function 2026-02-01 01:01:08 +01:00
33177fa035 Remove drag-and-drop functionality from App.tsx 2026-02-01 00:59:57 +01:00
074f27b835 Remove unused drag-and-drop functionality from App.tsx 2026-02-01 00:59:53 +01:00
8ad329c9cd Update dependency array in App.tsx 2026-01-31 19:12:23 +01:00
e3dc293f81 Remove unused function call in App.tsx 2026-01-31 19:12:23 +01:00
00613f60d5 Add input styles for prompt-item and folder-item 2026-01-31 19:10:39 +01:00
cd512e8456 Refactor and reorder functions in App.tsx 2026-01-31 19:10:28 +01:00
14203c33a1 Add folder and text editing functionality 2026-01-31 19:10:16 +01:00
f4120dfab3 Add state for editing folder and text in App.tsx 2026-01-31 19:05:37 +01:00
69f9b41708 Update README.md to include folder collapse state in UI settings description 2026-01-31 18:57:55 +01:00
a19c167769 Update README with new features 2026-01-31 18:56:48 +01:00
892e060bc1 Add no matching texts message for search results 2026-01-31 18:56:48 +01:00
7d758364e6 Update sort order logic to consider search state 2026-01-31 18:56:48 +01:00
15e61635a9 Refactor title update logic in App.tsx 2026-01-31 18:54:16 +01:00
131ef453a6 Add missing index for prompts folder in database migration 2026-01-31 18:54:10 +01:00
296e0242a1 Remove depth parameter from renderTextItem and renderFolder functions, update CSS for folder styling 2026-01-31 18:53:00 +01:00
3042a8e92d auto-git:
[change] src/App.tsx
2026-01-31 18:52:42 +01:00
a0a9e2360b Add renderTextItem and renderFolder functions in App.tsx 2026-01-31 18:50:12 +01:00
988e2ed55a auto-git:
[change] src/App.tsx
2026-01-31 18:49:58 +01:00
2297cab6a7 Add drag and drop functionality for texts and folders in App.tsx 2026-01-31 18:47:40 +01:00
7fae29bb71 Add folder path building and text/folder handling functions in App.tsx 2026-01-31 18:47:30 +01:00
e0e5a20d4d Add functionality to create new folders 2026-01-31 18:46:43 +01:00
96ef5f40f6 Update createText calls to include sortOrder parameter 2026-01-31 18:46:34 +01:00
197e71304a Add folder and text sorting logic in App.tsx 2026-01-31 18:46:25 +01:00
1f044ea362 Add folder and text sorting logic 2026-01-31 18:46:17 +01:00
361621a059 Add effect to refresh folders on component mount 2026-01-31 18:46:17 +01:00
e58c21bb38 Add refreshFolders function in App.tsx 2026-01-31 18:45:25 +01:00
d4497751fe Persist expandedFolders in local storage 2026-01-31 18:45:20 +01:00
1921b63ffc Add dragItemRef to App.tsx 2026-01-31 18:45:20 +01:00
354c974bd8 Add folder and expandedFolders state management in App.tsx 2026-01-31 18:45:12 +01:00
16a9dab119 Import Menu from @tauri-apps/api/menu in App.tsx 2026-01-31 18:45:12 +01:00
67dcb3f9e4 Add folder management functions and types in App.tsx 2026-01-31 18:44:53 +01:00
740af2021c Add folder and sort order functionality to database schema and operations 2026-01-31 18:44:22 +01:00
f5bc0dbe69 Update README.md with minor corrections 2026-01-31 17:01:58 +01:00
b8def8d0ab Add Markdown preview to TextDB description in README.md 2026-01-31 17:00:54 +01:00
0c68bfffe2 Update README.md with new features 2026-01-31 17:00:54 +01:00
f7175fc722 Enhance keydown handling in App.tsx 2026-01-31 16:58:34 +01:00
4bba163855 Remove unused 'Export PDF' button in App.tsx 2026-01-31 16:58:30 +01:00
788b312976 Enhance markdownToHTML to handle empty lines and content separation 2026-01-31 16:46:30 +01:00
45c9221fe9 Add margin styles for markdown paragraphs 2026-01-31 16:46:08 +01:00
6aff8091e3 Update markdown.js to convert line breaks to HTML paragraphs and handle block elements correctly 2026-01-31 16:45:04 +01:00
90d32279c3 Simplify and optimize line break handling in markdown.js 2026-01-31 16:44:00 +01:00
15993ed33c Improve line break handling in markdownToHTML function 2026-01-31 16:40:06 +01:00
4ae7f82288 Refactor line break handling in markdown.js 2026-01-31 16:36:44 +01:00
c2a6035f3d Improve markdown to HTML conversion by trimming breaks around code blocks 2026-01-31 16:30:11 +01:00
fa8d2807e8 Improve markdown to HTML conversion by trimming breaks around block elements 2026-01-31 16:30:05 +01:00
03deb8de1c Update markdown.js to handle line breaks and code blocks more effectively 2026-01-31 16:27:28 +01:00
b8d93c9ddf Remove top margin from first markdown heading 2026-01-31 16:26:21 +01:00
555c08fd0b Add padding to markdown preview in print mode 2026-01-31 16:24:01 +01:00
acb084dae9 Hide copy and external icons in print mode 2026-01-31 16:24:01 +01:00
eccd378043 Add 'core:webview:allow-print' permission to capabilities.json 2026-01-31 16:22:21 +01:00
05ef4ba130 Add webview print permission in main.json 2026-01-31 16:21:17 +01:00
c0293ac021 Add print styles for markdown content in index.css 2026-01-31 16:19:06 +01:00
735cc89e72 Add onClick handler to Print button in App.tsx 2026-01-31 16:19:06 +01:00
8cf0ec950a Add print functionality for markdown preview 2026-01-31 16:18:34 +01:00
1cd0d58206 Remove unused CSS properties in index.css 2026-01-31 16:17:51 +01:00
3d3b230125 Reorder and enhance Markdown preview button in editor footer 2026-01-31 16:14:23 +01:00
5855de1a2f Reset markdown preview state on certain conditions 2026-01-31 16:14:23 +01:00
b6187d1338 Update README.md to support .md files 2026-01-31 16:11:38 +01:00
15f1b22f3b Add Markdown file type configuration 2026-01-31 14:41:51 +01:00
afb7aac60e Support Markdown files in App.tsx 2026-01-31 14:41:51 +01:00
4a40ffc30c Update CSS for preview mode in editor 2026-01-31 14:37:26 +01:00
0c727df84e Add conditional class to editor textarea wrap 2026-01-31 14:37:26 +01:00
6601d2ef41 auto-git:
[change] src-tauri/gen/schemas/acl-manifests.json
 [change] src-tauri/gen/schemas/capabilities.json
 [change] src-tauri/gen/schemas/desktop-schema.json
 [change] src-tauri/gen/schemas/macOS-schema.json
 [change] src/App.tsx
2026-01-31 14:35:53 +01:00
c9243c77d2 auto-git:
[change] src-tauri/Cargo.lock
 [change] src-tauri/capabilities/main.json
2026-01-31 14:35:53 +01:00
6a5a2a38b1 auto-git:
[change] src-tauri/src/main.rs
2026-01-31 14:35:53 +01:00
fd6be4c6df auto-git:
[change] src-tauri/Cargo.toml
2026-01-31 14:35:53 +01:00
6bafb309f6 auto-git:
[change] package.json
2026-01-31 14:35:53 +01:00
158f9afb5e Set fixed size for markdown icons 2026-01-31 14:34:58 +01:00
09b395172e Add click event handler to markdown preview 2026-01-31 14:32:30 +01:00
7b1066605f auto-git:
[change] src-tauri/gen/schemas/acl-manifests.json
 [change] src-tauri/gen/schemas/capabilities.json
 [change] src-tauri/gen/schemas/desktop-schema.json
 [change] src-tauri/gen/schemas/macOS-schema.json
 [change] src/App.tsx
2026-01-31 14:31:11 +01:00
9f04d11664 auto-git:
[add] src-tauri/gen/schemas/acl-manifests.json
 [add] src-tauri/gen/schemas/capabilities.json
 [add] src-tauri/gen/schemas/desktop-schema.json
 [add] src-tauri/gen/schemas/macOS-schema.json
 [change] .gitignore
 [change] src-tauri/Cargo.lock
2026-01-31 14:31:08 +01:00
9fa2b8c663 auto-git:
[change] src-tauri/capabilities/main.json
2026-01-31 14:31:08 +01:00
8e4322550b auto-git:
[change] src-tauri/src/main.rs
2026-01-31 14:31:08 +01:00
a56a7ee647 auto-git:
[change] src-tauri/Cargo.toml
2026-01-31 14:31:08 +01:00
e1cb7a60e5 auto-git:
[change] package.json
2026-01-31 14:31:08 +01:00
49da9fccc4 Update markdown-render.css with scoped tokens and theme support 2026-01-31 14:25:28 +01:00
ec5bc01ab6 Update markdown.js to handle links correctly 2026-01-31 14:22:33 +01:00
fed15761ad Add styles for markdown preview in index.css 2026-01-31 14:21:09 +01:00
749fea672c Add markdown preview and export options in App component 2026-01-31 14:20:03 +01:00
bd6ffe6d41 Add markdown preview feature to App.tsx 2026-01-31 14:19:50 +01:00
700b9d72bb auto-git:
[add] src/markdown/markdown-render.css
 [add] src/markdown/markdown.js
2026-01-31 14:15:28 +01:00
b6f6ca8e1b Rename and enhance editor title row styling 2026-01-31 13:37:53 +01:00
3114b936f1 Refactor editor layout in App.tsx 2026-01-31 13:37:51 +01:00
31 changed files with 16879 additions and 538 deletions

3
.gitignore vendored
View File

@@ -11,4 +11,5 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
src-tauri/target/
src-tauri/gen/
src-tauri/gen/
*.lock

View File

@@ -1,19 +1,19 @@
# TextDB
TextDB is a **non-destructive**, completely offline, local-first text editor. Its core promise is simple: your edits never overwrite your history. Every manual save is immutable and append-only, and autosaves are kept as a separate draft layer. No accounts, no telemetry, no remote fonts, and no network calls.
TextDB is a **non-destructive**, completely offline, local-first text editor with built-in Markdown preview and local AI writing tools. Its core promise is simple: your edits never overwrite your history. Every manual save is immutable and append-only, and autosaves are kept as a separate draft layer. With a local Ollama model (recommended: `ministral:3b`), it can convert plain text to Markdown, proofread, summarize, translate, rewrite, change style, or run a custom instruction against a whole text or a selected passage. No accounts, no telemetry, no remote fonts, and no outside network calls.
## Features
- Non-destructive editing: manual saves are immutable; drafts never overwrite manual versions.
- Sidebar list of texts with live search (titles + version bodies).
- Editor with title, content area, and clear save status.
- Cmd/Ctrl+S creates a new immutable manual version.
- Autosave draft (debounced) that can be safely discarded.
- History panel listing versions by timestamp (drafts included when present).
- Export the current text to a `.txt` file.
- Open `.txt` files to create new entries from file content.
- Drag and drop `.txt` files onto the window to import them.
- Registers `.txt` file association for using TextDB as a default editor.
- Non-destructive editing: immutable manual saves + safe autosaved drafts.
- Version history panel with quick restore.
- Markdown preview mode with split view, printable output, clickable links, and automatic raw URL detection.
- Local AI tools via Ollama for Markdown conversion, proofreading, summarizing, translation, rewriting, style changes, and custom prompts.
- AI edits can run on the whole text or just the selected part, and results are written back as drafts.
- In-document search with Cmd/Ctrl+F, result highlighting, and next / previous navigation.
- Live document stats in the footer: characters, words, sentences, and estimated tokens.
- Sort your texts in nested folders with expandable tree; move items around via right-click.
- Open/import text files from the file picker or system “Open With”; export to `.txt`, export the database, or print / `.pdf`.
- Settings for theme, text size, line numbers, split view, Ollama connection, and editable AI prompt templates.
## Use cases
@@ -22,6 +22,7 @@ TextDB is a **non-destructive**, completely offline, local-first text editor. It
- **Research notes**: maintain evolving notes with an audit trail of changes.
- **Sensitive drafts**: keep local-only writing with immutable history and zero cloud sync.
- **Prompt or snippet library**: store and iterate on reusable text safely.
- **Offline writing assistant**: run local AI edits without sending text to external services.
## Tech stack
@@ -43,10 +44,13 @@ npm install
npm run tauri dev
```
For local AI tools, install Ollama: `https://ollama.com`
## Autosave + versioning behavior
- Typing triggers a debounced autosave (~600ms) that writes to the draft record.
- Manual saves (Cmd/Ctrl+S or the button) append a new version and clear any draft.
- AI edits preserve the current text state first when needed, then write the AI result back as a new draft.
- Autosave only overwrites the single draft row; manual versions are never overwritten.
- History shows all manual versions plus the current draft (if any).
@@ -55,3 +59,4 @@ npm run tauri dev
- SQLite is loaded via `Database.load("sqlite:text.db")`.
- The database file is stored in the Tauri app data directory.
- The app is fully offline by design: no telemetry, no external fonts, no CDNs.
- UI settings (theme, text size, line numbers, split view, sidebar + folder collapse state, last selected text, and AI prompt templates) are stored in localStorage.

View File

@@ -10,9 +10,15 @@
"tauri": "tauri"
},
"dependencies": {
"@codemirror/commands": "^6.10.1",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.4",
"@codemirror/view": "^6.39.12",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-clipboard-manager": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/plugin-sql": "^2.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"

639
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,12 +5,14 @@ description = "TextDB"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2", features = [] }
tauri-build = { version = "2.5", features = [] }
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri = { version = "2.10", features = [] }
tauri-plugin-clipboard-manager = { version = "2" }
tauri-plugin-dialog = { version = "2" }
tauri-plugin-fs = { version = "2" }
tauri-plugin-shell = { version = "2" }
tauri-plugin-sql = { version = "2", features = ["sqlite"] }

View File

@@ -5,9 +5,12 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:webview:allow-print",
"sql:default",
"sql:allow-execute",
"dialog:default",
"clipboard-manager:allow-write-text",
"shell:allow-open",
"fs:default",
"fs:allow-read-text-file",
"fs:allow-read-file",

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"main":{"identifier":"main","description":"Default capabilities for TextDB","local":true,"windows":["main"],"permissions":["core:default","core:webview:allow-print","sql:default","sql:allow-execute","dialog:default","clipboard-manager:allow-write-text","shell:allow-open","fs:default","fs:allow-read-text-file","fs:allow-read-file","fs:allow-read","fs:allow-home-read-recursive","fs:allow-download-read-recursive","fs:allow-document-read-recursive","fs:allow-desktop-read-recursive","fs:allow-write-file","fs:allow-write-text-file","fs:allow-home-write-recursive"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
src-tauri/icons/folder.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -13,13 +13,12 @@ fn take_pending_opens(state: tauri::State<PendingOpens>) -> Vec<String> {
fn main() {
tauri::Builder::default()
.setup(|app| {
app.manage(PendingOpens(Mutex::new(Vec::new())));
Ok(())
})
.manage(PendingOpens(Mutex::new(Vec::new())))
.invoke_handler(tauri::generate_handler![take_pending_opens])
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_sql::Builder::default().build())
.plugin(
tauri::plugin::Builder::<Wry, ()>::new("file-open")
@@ -34,9 +33,10 @@ fn main() {
if paths.is_empty() {
return;
}
let state = app.state::<PendingOpens>();
let mut pending = state.0.lock().expect("pending opens lock");
pending.extend(paths.iter().cloned());
if let Some(state) = app.try_state::<PendingOpens>() {
let mut pending = state.0.lock().expect("pending opens lock");
pending.extend(paths.iter().cloned());
}
let _ = app.emit("file-opened", paths);
}
})

View File

@@ -60,6 +60,20 @@
"contentTypes": [
"public.plain-text"
]
},
{
"ext": [
"md",
"markdown"
],
"name": "Markdown Document",
"description": "Markdown document",
"mimeType": "text/markdown",
"role": "Editor",
"rank": "Default",
"contentTypes": [
"net.daringfireball.markdown"
]
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,7 @@
--bg-active: #202020;
--border-strong: #555;
--danger-hover: #4f4f4f;
--tree-line: #5f5f5f;
}
body[data-theme="light"] {
@@ -41,6 +42,7 @@ body[data-theme="light"] {
--bg-active: #e6e6e6;
--border-strong: #b5b5b5;
--danger-hover: #d6d6d6;
--tree-line: #9a9a9a;
}
@@ -77,7 +79,7 @@ body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px 0;
padding: 24px 0 16px;
background: var(--panel);
border-right: 1px solid var(--border);
height: 100vh;
@@ -107,15 +109,17 @@ body {
.sidebar__footer {
display: flex;
flex-direction: column;
gap: 12px;
flex-direction: row;
align-items: center;
gap: 8px;
justify-content: center;
margin-top: auto;
padding-left: 24px;
padding-right: 24px;
padding: 0 24px;
}
.sidebar__footer .button {
width: 100%;
.sidebar__footer .icon-button {
width: 36px;
height: 36px;
}
.app-title {
@@ -131,6 +135,7 @@ body {
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--ink);
caret-color: var(--ink);
font-size: 0.95rem;
}
@@ -178,7 +183,6 @@ body {
}
.button--save {
margin-left: auto;
}
.prompt-list {
@@ -229,6 +233,17 @@ body {
flex: 1;
}
.prompt-item__input {
width: 100%;
font: inherit;
color: var(--ink);
background: var(--bg-input);
caret-color: var(--ink);
border: 1px solid var(--border);
border-radius: 10px;
padding: 6px 10px;
}
.prompt-item__delete {
width: 34px;
height: 34px;
@@ -243,7 +258,7 @@ body {
justify-content: center;
cursor: pointer;
position: absolute;
top: 8px;
top: 4px;
right: 8px;
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
}
@@ -260,16 +275,157 @@ body {
word-break: break-word;
}
.prompt-item__meta {
font-size: 0.8rem;
.folder-node {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid transparent;
}
.folder-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 14px;
border: 1px solid var(--tree-line);
background: var(--bg-elevated);
color: var(--ink);
cursor: pointer;
transition: none;
position: relative;
}
body:not([data-theme="light"]) .prompt-item,
body:not([data-theme="light"]) .folder-item {
background: var(--bg-contrast);
}
.folder-item__toggle {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--muted);
cursor: pointer;
padding: 0;
margin-left: -4px;
font-size: 1.6rem;
transform: translateY(-3px);
}
.folder-item__lead {
display: inline-flex;
align-items: center;
gap: 1px;
}
.folder-item__icon {
width: 1em;
height: 1em;
flex: 0 0 1em;
display: block;
}
.folder-item__title {
font-weight: 600;
flex: 1;
overflow-wrap: anywhere;
word-break: break-word;
}
.folder-item__delete {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
font-size: 1rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-left: 6px;
}
.folder-item__delete:hover {
color: var(--ink);
}
.folder-item__input {
width: 100%;
font: inherit;
color: var(--ink);
background: var(--bg-input);
caret-color: var(--ink);
border: 1px solid var(--border);
border-radius: 10px;
padding: 6px 10px;
}
.folder-children {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 6px;
padding-left: 18px;
position: relative;
}
.folder-children > * {
position: relative;
}
.folder-children > *::after {
content: "";
position: absolute;
left: -12px;
top: -10px;
bottom: -10px;
width: 1px;
background: var(--tree-line);
}
.folder-children > :first-child::after {
top: -16px;
}
.folder-children > :last-child::after {
bottom: 50%;
}
.folder-children .folder-item::before,
.folder-children .prompt-item::before {
content: "";
position: absolute;
left: -12px;
top: 50%;
width: 12px;
height: 1px;
background: var(--tree-line);
transform: translateY(-50%);
}
.workspace {
padding: 28px 32px;
padding: 16px 32px;
height: 100%;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
}
.workspace__body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.workspace__topbar {
@@ -293,7 +449,7 @@ body {
display: grid;
grid-template-columns: 1fr;
gap: 24px;
height: 100%;
flex: 1;
min-height: 0;
}
@@ -319,13 +475,16 @@ body {
gap: 8px;
}
.editor__status-row {
.editor__title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.editor__title-row .title-input {
flex: 1;
}
.title-input {
font-size: 1.6rem;
border: none;
@@ -333,6 +492,7 @@ body {
padding: 6px 2px 8px;
background: transparent;
color: var(--ink);
caret-color: var(--ink);
}
.title-input:focus {
@@ -348,6 +508,11 @@ body {
color: var(--muted);
}
.status-line--secondary {
margin-top: 4px;
font-size: 0.82rem;
}
.icon-button {
display: inline-flex;
align-items: center;
@@ -431,67 +596,190 @@ body {
overflow: hidden;
}
.editor__textarea-wrap--preview {
border-color: transparent;
}
body[data-theme="light"] .editor__textarea-wrap--preview {
background: var(--bg);
}
body:not([data-theme="light"]) .editor__textarea-wrap--preview {
background: var(--bg);
}
.editor__textarea-wrap--split {
flex-direction: row;
}
body[data-theme="light"] .editor__textarea-wrap--split {
background: var(--bg);
}
body:not([data-theme="light"]) .editor__textarea-wrap--split {
background: var(--bg);
}
.editor__textarea-wrap--split .editor__codemirror {
flex: 1;
border-right: 1px solid var(--border);
}
.editor__textarea-wrap--split .markdown-preview {
flex: 1;
}
body[data-theme="light"] .editor__textarea-wrap--split .markdown-preview {
background: var(--bg);
}
body:not([data-theme="light"]) .editor__textarea-wrap--split .markdown-preview {
background: var(--bg);
}
.editor__textarea-wrap:focus-within {
border-color: rgba(255, 255, 255, 0.18);
}
.line-numbers {
padding: 14px 10px;
min-width: 3ch;
text-align: right;
font-family: "SF Mono", "Menlo", "Consolas", monospace;
font-size: 0.95rem;
line-height: 1.5;
color: var(--muted);
background: var(--bg-contrast);
border-right: 1px solid var(--border);
user-select: none;
overflow: hidden;
}
.line-numbers__line {
height: 1.5em;
display: flex;
align-items: flex-start;
}
.line-measure {
position: absolute;
top: 0;
left: 0;
visibility: hidden;
pointer-events: none;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
font-family: "SF Mono", "Menlo", "Consolas", monospace;
font-size: 0.95rem;
line-height: 1.5;
padding: 14px;
}
.line-measure__line {
white-space: pre-wrap;
}
.editor__textarea {
.editor__codemirror {
flex: 1;
width: 100%;
resize: none;
border: none;
padding: 14px;
font-family: "SF Mono", "Menlo", "Consolas", monospace;
font-size: 0.95rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
min-height: 0;
}
.editor__codemirror--hidden {
display: none;
}
.editor__codemirror .cm-editor {
height: 100%;
background: transparent;
color: var(--ink);
font-family: inherit;
font-size: var(--base-font-size);
line-height: 1.5;
}
.editor__codemirror .cm-editor.cm-focused > .cm-scroller > .cm-cursorLayer .cm-cursor,
.editor__codemirror .cm-dropCursor {
border-left-color: #f5f5f5 !important;
border-left-width: 1.6px !important;
}
.editor__codemirror .cm-fatCursor {
background: rgba(245, 245, 245, 0.45);
}
.editor__codemirror .cm-scroller {
overflow: auto;
}
.editor__codemirror .cm-content {
padding: 14px;
}
.editor__codemirror .cm-gutters {
background: var(--bg-contrast);
border-right: 1px solid var(--border);
color: var(--muted);
}
.editor__codemirror .cm-gutterElement {
padding: 0 10px 0 0;
}
.editor__codemirror .cm-activeLineGutter {
background: transparent;
}
.editor__codemirror .cm-panels {
background: var(--bg-elevated);
color: var(--ink);
border-bottom: 1px solid var(--border);
}
.editor__codemirror .cm-panels-top {
border-bottom: 1px solid var(--border);
}
.editor__codemirror .cm-search {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
padding: 10px 12px;
}
.editor__codemirror .cm-search label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--muted);
}
.editor__codemirror .cm-search input,
.editor__codemirror .cm-search button {
font: inherit;
}
.editor__codemirror .cm-search input[type="text"] {
min-width: 220px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--ink);
}
.editor__textarea:focus {
outline: none;
.editor__codemirror .cm-search button {
border-radius: 10px;
border: 1px solid var(--border);
padding: 8px 10px;
background: var(--bg-input);
color: var(--ink);
cursor: pointer;
}
.editor__codemirror .cm-search button:hover {
border-color: var(--accent-strong);
}
.editor__codemirror .cm-search [name="close"] {
padding-left: 12px;
padding-right: 12px;
}
.editor__codemirror .cm-search input[type="checkbox"] {
accent-color: var(--ink);
}
.editor__codemirror .cm-searchMatch {
background: rgba(214, 214, 96, 0.28);
outline: 1px solid rgba(214, 214, 96, 0.38);
}
.editor__codemirror .cm-searchMatch.cm-searchMatch-selected {
background: rgba(233, 216, 112, 0.42);
}
.markdown-preview {
flex: 1;
width: 100%;
overflow-y: auto;
padding: 14px;
color: var(--ink);
}
body[data-theme="light"] .markdown-preview {
background: var(--bg);
}
body:not([data-theme="light"]) .markdown-preview {
background: var(--bg);
}
.markdown-preview--hidden {
display: none;
}
.editor__footer {
@@ -501,6 +789,23 @@ body {
align-items: center;
}
.editor__footer-status {
margin-left: auto;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.editor__stats {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
font-size: 0.82rem;
color: var(--muted);
}
.banner {
display: flex;
align-items: center;
@@ -658,6 +963,10 @@ body {
gap: 12px;
}
.modal__card--wide {
width: min(560px, 92vw);
}
.modal__title {
font-size: 1.2rem;
}
@@ -666,6 +975,37 @@ body {
color: var(--muted);
}
.modal__textarea {
width: 100%;
min-height: 170px;
resize: vertical;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--ink);
caret-color: var(--ink);
padding: 12px 14px;
font: inherit;
}
.modal__hint {
font-size: 0.8rem;
color: var(--muted);
}
.modal__selection-preview {
max-height: 180px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--ink);
padding: 12px 14px;
font-size: 0.9rem;
}
.modal__actions {
display: flex;
justify-content: flex-end;
@@ -756,6 +1096,8 @@ body {
inset: 0;
display: grid;
place-items: center;
padding: 16px;
overflow: auto;
z-index: 30;
}
@@ -768,7 +1110,8 @@ body {
.settings-panel {
position: relative;
z-index: 1;
width: min(420px, 92vw);
width: min(560px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
padding: 20px;
border-radius: 16px;
background: var(--bg-elevated);
@@ -777,6 +1120,7 @@ body {
display: flex;
flex-direction: column;
gap: 16px;
overflow: hidden;
}
.settings-panel__header {
@@ -789,17 +1133,55 @@ body {
font-size: 1.1rem;
}
.settings-panel__body {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
margin-right: -4px;
}
.settings-panel__section {
display: flex;
flex-direction: column;
gap: 8px;
}
.settings-panel__section--flush {
gap: 10px;
}
.settings-panel__section-title {
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin-top: 4px;
}
.settings-panel__label {
font-size: 0.9rem;
color: var(--muted);
}
.settings-panel__input,
.settings-panel__textarea {
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--ink);
caret-color: var(--ink);
padding: 8px 10px;
font-size: 0.95rem;
}
.settings-panel__textarea {
resize: vertical;
min-height: 120px;
}
.settings-panel__select {
border-radius: 10px;
border: 1px solid var(--border);
@@ -826,10 +1208,87 @@ body {
font-size: 0.9rem;
}
.settings-panel__hint {
font-size: 0.8rem;
color: var(--muted);
}
.settings-panel__status {
font-size: 0.8rem;
word-break: break-word;
}
.settings-panel__status--success {
color: var(--ink);
}
.settings-panel__status--error {
color: #d36b6b;
}
.prompt-accordion {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-input);
overflow: hidden;
}
.prompt-accordion__toggle {
width: 100%;
border: none;
background: transparent;
color: var(--ink);
padding: 12px 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font: inherit;
text-align: left;
cursor: pointer;
}
.prompt-accordion__indicator {
color: var(--muted);
font-size: 1.1rem;
line-height: 1;
}
.prompt-accordion__body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 14px 14px;
}
.prompt-accordion__actions {
display: flex;
justify-content: flex-end;
}
body[data-theme="light"] .editor__textarea-wrap:focus-within {
border-color: var(--accent-strong);
}
body[data-theme="light"] .editor__codemirror .cm-editor.cm-focused > .cm-scroller > .cm-cursorLayer .cm-cursor,
body[data-theme="light"] .editor__codemirror .cm-dropCursor {
border-left-color: #1f1f1f !important;
border-left-width: 1.6px !important;
}
body[data-theme="light"] .editor__codemirror .cm-fatCursor {
background: rgba(31, 31, 31, 0.28);
}
body[data-theme="light"] .editor__codemirror .cm-searchMatch {
background: rgba(219, 199, 81, 0.3);
outline-color: rgba(170, 141, 33, 0.28);
}
body[data-theme="light"] .editor__codemirror .cm-searchMatch.cm-searchMatch-selected {
background: rgba(219, 199, 81, 0.44);
}
.settings-panel__section--row {
flex-direction: row;
align-items: center;
@@ -839,3 +1298,60 @@ body[data-theme="light"] .editor__textarea-wrap:focus-within {
.settings-panel__section--row .settings-panel__label {
margin: 0;
}
@media print {
body.print-markdown {
--bg: #ffffff;
--bg-elevated: #ffffff;
--panel: #ffffff;
--ink: #111111;
--muted: #444444;
--border: #dddddd;
--bg-input: #ffffff;
--bg-contrast: #f5f5f5;
--bg-contrast-strong: #eeeeee;
background: #ffffff;
color: #111111;
}
body.print-markdown .app {
display: block;
height: auto;
}
body.print-markdown .sidebar,
body.print-markdown .history,
body.print-markdown .editor__header,
body.print-markdown .editor__footer {
display: none !important;
}
body.print-markdown .workspace__content {
grid-template-columns: 1fr;
gap: 0;
}
body.print-markdown .editor__textarea-wrap {
border-color: transparent;
background: transparent;
overflow: visible;
}
body.print-markdown .editor__codemirror {
display: none !important;
}
body.print-markdown .editor__textarea-wrap--split {
display: block;
}
body.print-markdown .markdown-preview {
padding: 32px 36px;
overflow: visible;
}
body.print-markdown .md-codeblock__copy,
body.print-markdown .md-icon-external {
display: none !important;
}
}

View File

@@ -6,6 +6,15 @@ export type Text = {
created_at: number;
updated_at: number;
last_saved_version_id: string | null;
folder_id: string | null;
};
export type Folder = {
id: string;
name: string;
parent_id: string | null;
created_at: number;
updated_at: number;
};
export type TextVersion = {
@@ -26,12 +35,21 @@ export type TextDraft = {
const MIGRATIONS = [
"PRAGMA foreign_keys = ON;",
`CREATE TABLE IF NOT EXISTS folders(
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
parent_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(parent_id) REFERENCES folders(id)
);`,
`CREATE TABLE IF NOT EXISTS prompts(
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_saved_version_id TEXT
last_saved_version_id TEXT,
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL
);`,
`CREATE TABLE IF NOT EXISTS prompt_versions(
id TEXT PRIMARY KEY,
@@ -50,7 +68,9 @@ const MIGRATIONS = [
FOREIGN KEY(prompt_id) REFERENCES prompts(id) ON DELETE CASCADE
);`,
"CREATE INDEX IF NOT EXISTS idx_prompt_versions_prompt_time ON prompt_versions(prompt_id, created_at DESC);",
"CREATE INDEX IF NOT EXISTS idx_prompts_updated ON prompts(updated_at DESC);"
"CREATE INDEX IF NOT EXISTS idx_prompts_updated ON prompts(updated_at DESC);",
"CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);",
"CREATE INDEX IF NOT EXISTS idx_folders_updated ON folders(updated_at DESC);"
];
let dbPromise: Promise<Database> | null = null;
@@ -59,6 +79,91 @@ async function migrate(db: Database) {
for (const statement of MIGRATIONS) {
await db.execute(statement);
}
await ensureColumn(db, "prompts", "folder_id", "TEXT REFERENCES folders(id) ON DELETE SET NULL");
await db.execute("CREATE INDEX IF NOT EXISTS idx_prompts_folder ON prompts(folder_id);");
await dropSortOrderColumns(db);
}
async function ensureColumn(
db: Database,
table: string,
column: string,
definition: string
) {
const columns = await db.select<{ name: string }[]>(
`PRAGMA table_info(${table})`
);
if (columns.some((col) => col.name === column)) return;
await db.execute(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
}
async function hasColumn(db: Database, table: string, column: string) {
const columns = await db.select<{ name: string }[]>(
`PRAGMA table_info(${table})`
);
return columns.some((col) => col.name === column);
}
async function dropSortOrderColumns(db: Database) {
const promptsHasSort = await hasColumn(db, "prompts", "sort_order");
const foldersHasSort = await hasColumn(db, "folders", "sort_order");
if (!promptsHasSort && !foldersHasSort) return;
await db.execute("PRAGMA foreign_keys = OFF");
await db.execute("BEGIN TRANSACTION");
try {
if (foldersHasSort) {
await db.execute("DROP TABLE IF EXISTS folders_new");
await db.execute(`
CREATE TABLE folders_new(
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
parent_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(parent_id) REFERENCES folders(id)
);
`);
await db.execute(`
INSERT INTO folders_new(id, name, parent_id, created_at, updated_at)
SELECT id, name, parent_id, created_at, updated_at FROM folders
`);
await db.execute("DROP TABLE folders");
await db.execute("ALTER TABLE folders_new RENAME TO folders");
}
if (promptsHasSort) {
await db.execute("DROP TABLE IF EXISTS prompts_new");
await db.execute(`
CREATE TABLE prompts_new(
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_saved_version_id TEXT,
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL
);
`);
await db.execute(`
INSERT INTO prompts_new(id, title, created_at, updated_at, last_saved_version_id, folder_id)
SELECT id, title, created_at, updated_at, last_saved_version_id, folder_id FROM prompts
`);
await db.execute("DROP TABLE prompts");
await db.execute("ALTER TABLE prompts_new RENAME TO prompts");
}
await db.execute("COMMIT");
} catch (error) {
await db.execute("ROLLBACK");
throw error;
} finally {
await db.execute("PRAGMA foreign_keys = ON");
}
await db.execute("CREATE INDEX IF NOT EXISTS idx_prompts_updated ON prompts(updated_at DESC);");
await db.execute("CREATE INDEX IF NOT EXISTS idx_prompts_folder ON prompts(folder_id);");
await db.execute("CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);");
await db.execute("CREATE INDEX IF NOT EXISTS idx_folders_updated ON folders(updated_at DESC);");
}
export async function getDb(): Promise<Database> {
@@ -79,7 +184,8 @@ export async function initDb() {
export async function listTexts(): Promise<Text[]> {
const db = await getDb();
const rows = await db.select<Text[]>(
"SELECT * FROM prompts ORDER BY updated_at DESC"
`SELECT * FROM prompts
ORDER BY updated_at DESC`
);
return rows;
}
@@ -144,7 +250,8 @@ export async function getDraft(promptId: string): Promise<TextDraft | null> {
export async function createText(
title: string,
body: string
body: string,
folderId: string | null = null
): Promise<{ textId: string; versionId: string; createdAt: number }> {
const db = await getDb();
const now = Date.now();
@@ -152,8 +259,8 @@ export async function createText(
const versionId = crypto.randomUUID();
await db.execute(
"INSERT INTO prompts(id, title, created_at, updated_at, last_saved_version_id) VALUES($1, $2, $3, $4, $5)",
[textId, title, now, now, versionId]
"INSERT INTO prompts(id, title, created_at, updated_at, last_saved_version_id, folder_id) VALUES($1, $2, $3, $4, $5, $6)",
[textId, title, now, now, versionId, folderId]
);
await db.execute(
@@ -166,6 +273,80 @@ export async function createText(
return { textId, versionId, createdAt: now };
}
export async function listFolders(): Promise<Folder[]> {
const db = await getDb();
return db.select<Folder[]>(
`SELECT * FROM folders
ORDER BY updated_at DESC`
);
}
export async function createFolder(
name: string,
parentId: string | null = null
): Promise<{ folderId: string; createdAt: number }> {
const db = await getDb();
const now = Date.now();
const folderId = crypto.randomUUID();
await db.execute(
"INSERT INTO folders(id, name, parent_id, created_at, updated_at) VALUES($1, $2, $3, $4, $5)",
[folderId, name, parentId, now, now]
);
return { folderId, createdAt: now };
}
export async function updateFolderName(folderId: string, name: string) {
const db = await getDb();
const now = Date.now();
await db.execute(
"UPDATE folders SET name = $1, updated_at = $2 WHERE id = $3",
[name, now, folderId]
);
}
export async function moveFolder(
folderId: string,
parentId: string | null
) {
const db = await getDb();
const now = Date.now();
await db.execute(
"UPDATE folders SET parent_id = $1, updated_at = $2 WHERE id = $3",
[parentId, now, folderId]
);
}
export async function deleteFolder(folderId: string) {
const db = await getDb();
const now = Date.now();
const rows = await db.select<{ parent_id: string | null }[]>(
"SELECT parent_id FROM folders WHERE id = $1 LIMIT 1",
[folderId]
);
const parentId = rows[0]?.parent_id ?? null;
await db.execute(
"UPDATE folders SET parent_id = $1, updated_at = $2 WHERE parent_id = $3",
[parentId, now, folderId]
);
await db.execute(
"UPDATE prompts SET folder_id = $1, updated_at = $2 WHERE folder_id = $3",
[parentId, now, folderId]
);
await db.execute("DELETE FROM folders WHERE id = $1", [folderId]);
}
export async function moveTextToFolder(
textId: string,
folderId: string | null
) {
const db = await getDb();
const now = Date.now();
await db.execute(
"UPDATE prompts SET folder_id = $1, updated_at = $2 WHERE id = $3",
[folderId, now, textId]
);
}
export async function saveManualVersion(
promptId: string,
title: string,
@@ -253,3 +434,9 @@ export async function deleteTextVersion(promptId: string, versionId: string) {
[nextId, promptId]
);
}
export async function exportDatabaseSnapshot(destinationPath: string) {
const db = await getDb();
const escapedPath = destinationPath.replace(/'/g, "''");
await db.execute(`VACUUM INTO '${escapedPath}'`);
}

View File

@@ -0,0 +1,287 @@
/* Scoped tokens to avoid bleeding into the app */
.md-root {
--md-border-color: var(--border);
--md-surface: var(--bg-contrast);
--md-surface-alt: var(--bg-input);
--md-radius: 12px;
--md-text-color: var(--ink);
--md-muted-color: var(--muted);
--md-link-color: #5aa2ff;
--md-link-hover-color: #7bb6ff;
--md-code-bg: var(--bg-contrast);
--md-code-inline-bg: rgba(255, 255, 255, 0.08);
--md-code-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
}
body:not([data-theme="light"]) .md-root {
--md-surface-alt: var(--bg);
}
body[data-theme="light"] .md-root {
--md-link-color: #0a66c2;
--md-link-hover-color: #004182;
--md-surface-alt: var(--bg);
--md-code-inline-bg: rgba(0, 0, 0, 0.06);
}
/* Optional wrapper if you choose to wrap markdown output:
<div class="md-root" v-html="markdownToHTML(text)"> */
.md-root {
color: var(--md-text-color);
font-size: 1rem;
line-height: 1.6;
}
/* Headings */
.md-heading {
margin: 1.5rem 0 0.75rem;
font-weight: 600;
line-height: 1.25;
color: var(--md-text-color);
}
.md-root > .md-heading:first-child {
margin-top: 0;
}
.md-heading--1 {
font-size: 1.8rem;
}
.md-heading--2 {
font-size: 1.5rem;
}
.md-heading--3 {
font-size: 1.25rem;
}
.md-heading--4 {
font-size: 1.1rem;
}
.md-paragraph {
margin: 0 0 0.9rem;
}
.md-paragraph:last-child {
margin-bottom: 0;
}
/* Horizontal rule */
.md-hr {
border: 0;
border-top: 1px solid var(--md-border-color);
margin: 1.5rem 0;
}
/* Lists */
.md-list {
padding-left: 1.4rem;
margin: 0.5rem 0 1rem;
}
.md-list--unordered {
list-style: disc;
}
.md-list--ordered {
list-style: decimal;
}
.md-list__item {
margin: 0.2rem 0;
}
/* Tables */
.md-table {
border-collapse: separate;
border-spacing: 0;
width: 100%;
margin: 1rem 0;
border: 1px solid var(--md-border-color);
border-radius: var(--md-radius);
overflow: hidden;
}
.md-table thead {
background: var(--md-surface);
}
.md-table__row {
}
.md-table__row--head {
}
.md-table__head-cell,
.md-table__cell {
padding: 0.6rem 0.75rem;
vertical-align: top;
text-align: left;
border-bottom: 1px solid var(--md-border-color);
}
.md-table__row:last-child .md-table__cell {
border-bottom: none;
}
.md-align-left {
text-align: left;
}
.md-align-center {
text-align: center;
}
.md-align-right {
text-align: right;
}
/* Blockquotes */
.md-blockquote {
margin: 1rem 0;
padding: 0.75rem 1rem;
border-left: 3px solid var(--md-border-color);
background: var(--md-surface);
color: var(--md-muted-color);
}
/* Code blocks */
.md-codeblock {
margin: 1rem 0;
border: 1px solid var(--md-border-color);
border-radius: var(--md-radius);
overflow: hidden;
background: var(--md-surface-alt);
}
.md-codeblock__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--md-surface);
border-bottom: 1px solid var(--md-border-color);
}
.md-codeblock__lang {
font-size: 0.875rem;
font-weight: 600;
color: var(--md-text-color);
}
.md-codeblock__copy {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border: 1px solid var(--md-border-color);
border-radius: 8px;
background: var(--md-surface-alt);
color: var(--md-text-color);
cursor: pointer;
}
.md-codeblock__copy:hover {
background: var(--md-surface);
}
.md-codeblock__copy:active {
transform: translateY(1px);
}
.md-icon {
width: 1em;
height: 1em;
stroke: currentColor;
fill: none;
flex-shrink: 0;
}
/* Could be specialized later if needed */
.md-icon-external {
}
.md-icon-copy {
}
.md-codeblock__pre {
margin: 0;
padding: 0.75rem;
border: 0;
overflow: auto;
max-width: 100%;
background: var(--md-code-bg);
}
.md-codeblock__code {
display: block;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
max-width: 100%;
font-family: var(--md-code-font);
font-size: 0.95em;
}
/* Inline code (outside of fenced blocks) */
code:not(.md-codeblock__code) {
font-family: var(--md-code-font);
font-size: 0.95em;
padding: 0.1em 0.25em;
border-radius: 4px;
background: var(--md-code-inline-bg);
}
/* Links */
.md-link {
color: var(--md-link-color);
text-decoration: underline;
}
.md-link:hover,
.md-link:focus-visible {
color: var(--md-link-hover-color);
}
.md-link--external {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.md-link__label {
/* can be customized if needed */
}
.md-link__tooltip {
position: absolute;
left: 0;
top: 100%;
margin-top: 0.35rem;
padding: 0.35rem 0.5rem;
background: var(--bg-contrast-strong);
color: var(--md-text-color);
border-radius: 6px;
font-size: 0.75rem;
white-space: nowrap;
opacity: 0;
transform: translateY(4px);
transition: opacity 120ms ease, transform 120ms ease;
pointer-events: none;
z-index: 10;
}
.md-link--external:hover .md-link__tooltip,
.md-link--external:focus-visible .md-link__tooltip {
opacity: 1;
transform: translateY(0);
}

345
src/markdown/markdown.js Normal file
View File

@@ -0,0 +1,345 @@
export function markdownToHTML(text) {
// 0) Remove <think>...</think>/<thinking>...</thinking> blocks
text = text.replace(/<think(?:ing)?>[\s\S]*?(?:<\/think(?:ing)?>|$)/gi, '');
// Normalize exotic spaces (narrow/non-breaking) to regular spaces
text = text.replace(/[\u00a0\u202f\u2007]/g, ' ');
text = balanceStreamingCodeFence(text);
const escapeHtml = (value = '') =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const escapeAttr = (value = '') =>
escapeHtml(value)
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const applyInline = (source) => {
const codeRuns = [];
let tmp = source.replace(/`([^`]+?)`/g, (_, code) => {
const idx = codeRuns.push(code) - 1;
return `@@CODEINLINE${idx}@@`;
});
const strongRuns = [];
tmp = tmp.replace(/\*\*([\s\S]+?)\*\*/g, (_, content) => {
const idx = strongRuns.push(content) - 1;
return `@@STRONG${idx}@@`;
});
const emphasisRuns = [];
tmp = tmp.replace(/(?<!\*)\*([\s\S]+?)\*(?!\*)/g, (_, content) => {
const idx = emphasisRuns.push(content) - 1;
return `@@EM${idx}@@`;
});
return tmp
.replace(/@@STRONG(\d+)@@/g, (_, idx) => `<b>${strongRuns[+idx]}</b>`)
.replace(/@@EM(\d+)@@/g, (_, idx) => `<i>${emphasisRuns[+idx]}</i>`)
.replace(/@@CODEINLINE(\d+)@@/g, (_, idx) => `<code>${codeRuns[+idx]}</code>`);
};
// 1) Normalize line endings
let tmp = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// 2) Extract code blocks and replace with placeholders (protect from all formatting)
const codeblocks = [];
const placeholder = (idx) => `@@CODEBLOCK${idx}@@`;
tmp = tmp.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_, lang, code) => {
let cleaned = (code || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const lines = cleaned.split('\n');
while (lines.length > 0 && /^\s*$/.test(lines[lines.length - 1])) lines.pop();
cleaned = lines.join('\n');
codeblocks.push({ lang: (lang || '').trim(), code: cleaned });
return placeholder(codeblocks.length - 1);
});
// 3) HTML-escape special characters (outside of fenced code blocks)
let escaped = escapeHtml(tmp);
// 4) Headings (with consistent hooks)
escaped = escaped
.replace(/^#### (.+)$/gm, '<h4 class="md-heading md-heading--4">$1</h4>')
.replace(/^### (.+)$/gm, '<h3 class="md-heading md-heading--3">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 class="md-heading md-heading--2">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 class="md-heading md-heading--1">$1</h1>');
// 4.1) Horizontal rules: --- or *** or ___ on a line
escaped = escaped.replace(/^(?:-{3,}|\*{3,}|_{3,})\s*$/gm, '<hr class="md-hr">');
// 4.2) Ordered lists: lines starting with "1. ", "2. ", ...
escaped = escaped.replace(
/(^|\n)([ \t]*\d+\. .+(?:\n[ \t]*\d+\. .+)*)/g,
(_, lead, listBlock) => {
const items = listBlock
.split(/\n/)
.map((line) => line.replace(/^[ \t]*\d+\.\s+/, '').trim())
.filter((item) => item.length > 0)
.map((item) => `<li class="md-list__item">${item}</li>`)
.join('');
if (!items) return listBlock;
return `${lead}<ol class="md-list md-list--ordered">${items}</ol>`;
}
);
// 4.3) Blockquotes
escaped = escaped.replace(
/(^|\n)([ \t]*> .+(?:\n[ \t]*> .+)*)/g,
(_, lead, blockquoteBlock) => {
const lines = blockquoteBlock
.split(/\n/)
.map((line) => line.replace(/^[ \t]*>\s*/, '').trim())
.join('<br />');
return `${lead}<blockquote class="md-blockquote">${lines}</blockquote>`;
}
);
// 4.5) Unordered lists
escaped = escaped.replace(
/(^|\n)([ \t]*[-*] .+(?:\n[ \t]*[-*] .+)*)/g,
(_, lead, listBlock) => {
const items = listBlock
.split(/\n/)
.map((line) => line.replace(/^[ \t]*[-*]\s+/, '').trim())
.filter((item) => item.length > 0)
.map((item) => `<li class="md-list__item">${item}</li>`)
.join('');
if (!items) return listBlock;
return `${lead}<ul class="md-list md-list--unordered">${items}</ul>`;
}
);
// 4.6) Markdown tables (GitHub-style). Strict: requires header, separator, ≥2 cols.
const mdTableBlockRe =
/(^\|[^\n]*\|?\s*\n\|\s*[:\-]+(?:\s*\|\s*[:\-]+)+\s*\|?\s*\n(?:\|[^\n]*\|?\s*(?:\n|$))*)/gm;
escaped = escaped.replace(mdTableBlockRe, (block) => {
const hadTrailingNewline = /\n$/.test(block);
const lines = block.replace(/\n$/, '').split('\n');
const split = (line) => line.replace(/^\||\|$/g, '').split('|').map((s) => s.trim());
const headers = split(lines[0]);
const seps = split(lines[1]);
if (headers.length < 2 || seps.length < 2) return block;
if (!seps.every((s) => /^[ :\-]+$/.test(s) && /-/.test(s))) return block;
const aligns = seps.map((seg) => {
const s = seg.replace(/\s+/g, '');
const left = s.startsWith(':');
const right = s.endsWith(':');
if (left && right) return 'center';
if (right) return 'right';
return 'left';
});
const bodyLines = lines.slice(2).filter((l) => /^\|/.test(l.trim()));
const alignClass = (i) => `md-align-${aligns[i] || 'left'}`;
const ths = headers
.map(
(h, i) =>
`<th class="md-table__head-cell ${alignClass(i)}">${h}</th>`
)
.join('');
const rows = bodyLines
.map((line) => {
const cells = split(line);
const tds = cells
.map(
(c, i) =>
`<td class="md-table__cell ${alignClass(i)}">${c}</td>`
)
.join('');
return `<tr class="md-table__row">${tds}</tr>`;
})
.join('');
const table = `<table class="md-table"><thead><tr class="md-table__row md-table__row--head">${ths}</tr></thead><tbody>${rows}</tbody></table>`;
return table + (hadTrailingNewline ? '\n' : '');
});
// 5) Bold, italic, inline code
let html = applyInline(escaped);
// 5.5) Links
const safeLink = (hrefRaw) => {
const href = (hrefRaw || '').trim();
if (!href) return '';
if (/^https?:\/\//i.test(href)) return href;
if (/^mailto:/i.test(href) || /^tel:/i.test(href)) return href;
if (href.startsWith('/') || href.startsWith('#')) return href;
return '';
};
const renderExternalLink = (label, hrefRaw) => {
const url = safeLink(hrefRaw);
const tooltip = escapeHtml(hrefRaw || '');
if (!url) return label;
return `<a class="md-link md-link--external" href="${escapeAttr(
url
)}" target="_blank" rel="noreferrer noopener"><span class="md-link__label">${label}</span> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="md-icon md-icon-external"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg><span class="md-link__tooltip">${tooltip}</span></a>`;
};
html = html.replace(/\[([^\]]+?)\]\(([^)]+?)\)/g, (_, label, href) => {
return renderExternalLink(label, href);
});
const autoLinkPlainUrls = (source) => {
const protectedChunkRe = /(<a\b[\s\S]*?<\/a>|<code>[\s\S]*?<\/code>|@@CODEBLOCK\d+@@)/g;
const isProtectedChunk = /^(<a\b[\s\S]*<\/a>|<code>[\s\S]*<\/code>|@@CODEBLOCK\d+@@)$/;
const trimTrailingPunctuation = (value) => {
let linked = value;
let trailing = '';
while (linked.length > 0) {
const last = linked[linked.length - 1];
if (!/[)\].,!?;:]/.test(last)) break;
if (last === ')') {
const opens = (linked.match(/\(/g) || []).length;
const closes = (linked.match(/\)/g) || []).length;
if (closes <= opens) break;
}
linked = linked.slice(0, -1);
trailing = last + trailing;
}
return { linked, trailing };
};
return source
.split(protectedChunkRe)
.map((part) => {
if (!part || isProtectedChunk.test(part)) return part;
return part.replace(/(^|[\s(>])((?:https?:\/\/|www\.)[^\s<]+)/g, (_, lead, rawUrl) => {
const { linked, trailing } = trimTrailingPunctuation(rawUrl);
if (!linked) return `${lead}${rawUrl}`;
const href = /^www\./i.test(linked) ? `https://${linked}` : linked;
return `${lead}${renderExternalLink(linked, href)}${trailing}`;
});
})
.join('');
};
html = autoLinkPlainUrls(html);
// 6) Convert line-breaks to HTML paragraphs and <br /> inside paragraphs
const linesWithHtml = html.split("\n");
const htmlLines = [];
let paragraph = [];
let emptyCount = 0;
let seenContent = false;
const flushParagraph = () => {
if (paragraph.length === 0) return;
htmlLines.push(`<p class="md-paragraph">${paragraph.join("<br />")}</p>`);
paragraph = [];
seenContent = true;
};
const pushExtraBreaks = () => {
if (!seenContent) {
emptyCount = 0;
return;
}
const extraBreaks = Math.max(0, emptyCount - 1);
for (let j = 0; j < extraBreaks; j += 1) {
htmlLines.push("<br />");
}
emptyCount = 0;
};
const isBlockLine = (value) => {
const trimmed = value.trim();
if (!trimmed) return false;
if (/^@@CODEBLOCK\d+@@$/.test(trimmed)) return true;
if (!trimmed.startsWith("<")) return false;
return /^(<h[1-4]\b|<hr\b|<table\b|<ul\b|<ol\b|<blockquote\b|<div class="md-codeblock"\b|<\/(?:h[1-4]|table|ul|ol|blockquote)>)/.test(
trimmed
);
};
for (let i = 0; i < linesWithHtml.length; i += 1) {
const line = linesWithHtml[i] ?? "";
if (line.trim().length === 0) {
if (paragraph.length > 0) {
flushParagraph();
}
emptyCount += 1;
continue;
}
if (isBlockLine(line)) {
if (paragraph.length > 0) {
flushParagraph();
}
pushExtraBreaks();
htmlLines.push(line);
seenContent = true;
continue;
}
if (emptyCount > 0) {
pushExtraBreaks();
}
paragraph.push(line);
}
flushParagraph();
html = htmlLines.join("");
// 7) Restore code blocks with header + copy button
html = html.replace(/@@CODEBLOCK(\d+)@@/g, (_, idx) => {
const { lang, code } = codeblocks[+idx];
const title = (lang && lang.trim()) ? lang.trim() : 'code';
const titleLabel = escapeHtml(title);
const languageClass = title.toLowerCase().replace(/[^a-z0-9_-]/g, '') || 'code';
const escapedCode = escapeHtml(code);
const encodedForCopy = encodeURIComponent(code);
const head = `<div class="md-codeblock__header"><div class="md-codeblock__lang">${titleLabel}</div><button type="button" class="md-codeblock__copy" aria-label="Copy code" title="Copy code" data-copy-code="${escapeAttr(
encodedForCopy
)}"><svg class="md-icon md-icon-copy" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M16 1H4a2 2 0 0 0-2 2v12h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h11v14z"/></svg></button></div>`;
const body = `<pre class="md-codeblock__pre"><code class="md-codeblock__code language-${languageClass}">${escapedCode}</code></pre>`;
return `<div class="md-codeblock">${head}${body}</div>`;
});
return html;
}
// Virtually close an unfinished fenced code block so it renders during streaming.
function balanceStreamingCodeFence(md) {
const lines = md.split(/\r?\n/);
let open = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!open) {
const m = line.match(/^\s*([`~]{3,})([^\s]*)?.*$/);
if (m) {
open = { fenceChar: m[1][0], fenceLen: m[1].length };
continue;
}
} else {
const re = new RegExp(
`^\\s*(${open.fenceChar}{${open.fenceLen},})\\s*$`
);
if (re.test(line)) {
open = null;
continue;
}
}
}
if (open) {
const virtual = `${open.fenceChar.repeat(open.fenceLen)}`;
return md.endsWith('\n') ? md + virtual : md + '\n' + virtual;
}
return md;
}