1+ import React , { useState , useEffect , useCallback , useRef } from 'react' ;
2+ import styles from './CopyMarkdown.module.css' ;
3+
4+ function CopyMarkdown ( ) {
5+ const [ isOpen , setIsOpen ] = useState ( false ) ;
6+ const [ hasContent , setHasContent ] = useState ( false ) ;
7+ const buttonRef = useRef ( null ) ;
8+ const dropdownRef = useRef ( null ) ;
9+
10+ // Generate clean markdown from the page content
11+ const generateCleanMarkdown = useCallback ( ( ) => {
12+ const content = document . querySelector ( '.theme-doc-markdown.markdown' ) ;
13+ if ( ! content ) {
14+ return 'Could not find content to convert to markdown.' ;
15+ }
16+
17+ // Clone the content to avoid modifying the original
18+ const contentClone = content . cloneNode ( true ) ;
19+
20+ // Get the title
21+ const title = contentClone . querySelector ( 'h1' ) ?. textContent || 'Untitled' ;
22+
23+ // Remove elements we don't want in the markdown
24+ const elementsToRemove = contentClone . querySelectorAll ( '.theme-edit-this-page, .pagination-nav, .table-of-contents' ) ;
25+ elementsToRemove . forEach ( el => el ?. remove ( ) ) ;
26+
27+ // Convert the HTML content to markdown
28+ let markdown = `# ${ title } \n\n` ;
29+
30+ // Process paragraphs, headers, lists, etc.
31+ const elements = contentClone . querySelectorAll ( 'p, h2, h3, h4, h5, h6, ul, ol, pre, blockquote, table' ) ;
32+ elements . forEach ( el => {
33+ // Skip the title as we've already added it
34+ if ( el . tagName === 'H1' ) return ;
35+
36+ // Process different element types
37+ switch ( el . tagName ) {
38+ case 'H2' :
39+ markdown += `\n## ${ el . textContent } \n\n` ;
40+ break ;
41+ case 'H3' :
42+ markdown += `\n### ${ el . textContent } \n\n` ;
43+ break ;
44+ case 'H4' :
45+ markdown += `\n#### ${ el . textContent } \n\n` ;
46+ break ;
47+ case 'H5' :
48+ markdown += `\n##### ${ el . textContent } \n\n` ;
49+ break ;
50+ case 'H6' :
51+ markdown += `\n###### ${ el . textContent } \n\n` ;
52+ break ;
53+ case 'P' :
54+ markdown += `${ el . textContent } \n\n` ;
55+ break ;
56+ case 'UL' :
57+ markdown += processListItems ( el , '- ' ) ;
58+ break ;
59+ case 'OL' :
60+ markdown += processListItems ( el , ( i ) => `${ i + 1 } . ` ) ;
61+ break ;
62+ case 'PRE' :
63+ const codeElement = el . querySelector ( 'code' ) ;
64+ const codeClass = codeElement ?. className ?. match ( / l a n g u a g e - ( \w + ) / ) ?. [ 1 ] || '' ;
65+ const codeContent = codeElement ?. textContent || el . textContent ;
66+ markdown += `\`\`\`${ codeClass } \n${ codeContent } \n\`\`\`\n\n` ;
67+ break ;
68+ case 'BLOCKQUOTE' :
69+ const blockquoteLines = el . textContent . split ( '\n' ) . map ( line => `> ${ line } ` ) . join ( '\n' ) ;
70+ markdown += `${ blockquoteLines } \n\n` ;
71+ break ;
72+ case 'TABLE' :
73+ markdown += processTable ( el ) ;
74+ break ;
75+ default :
76+ markdown += `${ el . textContent } \n\n` ;
77+ }
78+ } ) ;
79+
80+ return markdown . trim ( ) ;
81+ } , [ ] ) ;
82+
83+ // Helper function to process list items
84+ const processListItems = ( listElement , prefix ) => {
85+ let result = '\n' ;
86+ const items = listElement . querySelectorAll ( 'li' ) ;
87+ items . forEach ( ( item , index ) => {
88+ // If prefix is a function, call it with the index
89+ const prefixText = typeof prefix === 'function' ? prefix ( index ) : prefix ;
90+ result += `${ prefixText } ${ item . textContent } \n` ;
91+
92+ // Process any nested lists
93+ const nestedLists = item . querySelectorAll ( 'ul, ol' ) ;
94+ if ( nestedLists . length > 0 ) {
95+ nestedLists . forEach ( nestedList => {
96+ const nestedPrefix = nestedList . tagName === 'UL' ? ' - ' : ( i ) => ` ${ i + 1 } . ` ;
97+ result += processListItems ( nestedList , nestedPrefix ) ;
98+ } ) ;
99+ }
100+ } ) ;
101+ return result + '\n' ;
102+ } ;
103+
104+ // Helper function to process tables
105+ const processTable = ( tableElement ) => {
106+ let result = '\n' ;
107+ const rows = tableElement . querySelectorAll ( 'tr' ) ;
108+
109+ // Process header row
110+ const headerCells = rows [ 0 ] ?. querySelectorAll ( 'th' ) || [ ] ;
111+ if ( headerCells . length > 0 ) {
112+ result += '| ' + Array . from ( headerCells ) . map ( cell => cell . textContent ) . join ( ' | ' ) + ' |\n' ;
113+ result += '| ' + Array . from ( headerCells ) . map ( ( ) => '---' ) . join ( ' | ' ) + ' |\n' ;
114+ }
115+
116+ // Process data rows
117+ for ( let i = headerCells . length > 0 ? 1 : 0 ; i < rows . length ; i ++ ) {
118+ const cells = rows [ i ] . querySelectorAll ( 'td' ) ;
119+ result += '| ' + Array . from ( cells ) . map ( cell => cell . textContent ) . join ( ' | ' ) + ' |\n' ;
120+ }
121+
122+ return result + '\n' ;
123+ } ;
124+
125+ // Toggle dropdown
126+ const toggleDropdown = useCallback ( ( ) => {
127+ setIsOpen ( prev => ! prev ) ;
128+ } , [ ] ) ;
129+
130+ // Copy markdown to clipboard
131+ const copyMarkdown = useCallback ( async ( ) => {
132+ try {
133+ const markdown = generateCleanMarkdown ( ) ;
134+ await navigator . clipboard . writeText ( markdown ) ;
135+ setIsOpen ( false ) ;
136+ showToast ( 'Markdown copied to clipboard!' ) ;
137+ } catch ( error ) {
138+ console . error ( 'Failed to copy markdown:' , error ) ;
139+ showToast ( 'Failed to copy. Please try again.' , true ) ;
140+ }
141+ } , [ generateCleanMarkdown ] ) ;
142+
143+ // View as plain text
144+ const viewAsMarkdown = useCallback ( ( ) => {
145+ try {
146+ const markdown = generateCleanMarkdown ( ) ;
147+ const newWindow = window . open ( ) ;
148+ if ( newWindow ) {
149+ newWindow . document . write ( `<html><head><title>Markdown Content</title></head><body><pre>${ markdown } </pre></body></html>` ) ;
150+ newWindow . document . close ( ) ;
151+ } else {
152+ showToast ( 'Popup was blocked. Please allow popups for this site.' , true ) ;
153+ }
154+ setIsOpen ( false ) ;
155+ } catch ( error ) {
156+ console . error ( 'Failed to view markdown:' , error ) ;
157+ showToast ( 'Failed to open view. Please try again.' , true ) ;
158+ }
159+ } , [ generateCleanMarkdown ] ) ;
160+
161+ // Open in ChatGPT
162+ const openInChatGpt = useCallback ( ( ) => {
163+ try {
164+ const markdown = generateCleanMarkdown ( ) ;
165+ const baseUrl = 'https://chat.openai.com/' ;
166+ const newWindow = window . open ( baseUrl ) ;
167+ if ( newWindow ) {
168+ // Also copy to clipboard
169+ navigator . clipboard . writeText ( markdown ) ;
170+ showToast ( "ChatGPT opened. Please paste the copied markdown there." ) ;
171+ } else {
172+ showToast ( 'Popup was blocked. Please allow popups for this site.' , true ) ;
173+ }
174+ setIsOpen ( false ) ;
175+ } catch ( error ) {
176+ console . error ( 'Failed to open ChatGPT:' , error ) ;
177+ showToast ( 'Failed to open ChatGPT. Please try again.' , true ) ;
178+ }
179+ } , [ generateCleanMarkdown ] ) ;
180+
181+ // Show toast notification
182+ const showToast = ( message , isError = false ) => {
183+ const toast = document . createElement ( 'div' ) ;
184+ toast . className = styles . toast ;
185+ if ( isError ) {
186+ toast . classList . add ( styles . errorToast ) ;
187+ }
188+ toast . textContent = message ;
189+ document . body . appendChild ( toast ) ;
190+
191+ setTimeout ( ( ) => {
192+ toast . style . opacity = '0' ;
193+ setTimeout ( ( ) => {
194+ if ( document . body . contains ( toast ) ) {
195+ document . body . removeChild ( toast ) ;
196+ }
197+ } , 500 ) ;
198+ } , 3000 ) ;
199+ } ;
200+
201+ // Handle click outside to close dropdown
202+ const handleClickOutside = useCallback ( ( event ) => {
203+ // Only close if clicking outside both the button and dropdown
204+ if (
205+ buttonRef . current &&
206+ ! buttonRef . current . contains ( event . target ) &&
207+ dropdownRef . current &&
208+ ! dropdownRef . current . contains ( event . target )
209+ ) {
210+ setIsOpen ( false ) ;
211+ }
212+ } , [ ] ) ;
213+
214+ // Initialize on client side
215+ useEffect ( ( ) => {
216+ // Check if we have markdown content
217+ const hasMarkdownContent = ! ! document . querySelector ( '.theme-doc-markdown.markdown h1' ) ;
218+ setHasContent ( hasMarkdownContent ) ;
219+
220+ // Set up click outside handler
221+ document . addEventListener ( 'click' , handleClickOutside ) ;
222+
223+ return ( ) => {
224+ // Clean up event listener
225+ document . removeEventListener ( 'click' , handleClickOutside ) ;
226+ } ;
227+ } , [ handleClickOutside ] ) ;
228+
229+ // Don't render on pages that don't have markdown content
230+ if ( ! hasContent ) {
231+ return null ;
232+ }
233+
234+ return (
235+ < div className = { styles . container } >
236+ < button
237+ ref = { buttonRef }
238+ className = { styles . button }
239+ onClick = { toggleDropdown }
240+ aria-expanded = { isOpen }
241+ aria-haspopup = "true"
242+ >
243+ < span > Copy page</ span >
244+ < svg className = { styles . icon } viewBox = "0 0 20 20" fill = "currentColor" >
245+ < path
246+ fillRule = "evenodd"
247+ d = "M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
248+ clipRule = "evenodd"
249+ />
250+ </ svg >
251+ </ button >
252+
253+ { isOpen && (
254+ < div
255+ ref = { dropdownRef }
256+ className = { styles . dropdown }
257+ role = "menu"
258+ aria-orientation = "vertical"
259+ >
260+ < ul className = { styles . list } >
261+ < li className = { styles . item } >
262+ < button
263+ className = { styles . actionButton }
264+ onClick = { copyMarkdown }
265+ role = "menuitem"
266+ >
267+ < span > Copy page as Markdown for LLMs</ span >
268+ </ button >
269+ </ li >
270+ < li className = { styles . item } >
271+ < button
272+ className = { styles . actionButton }
273+ onClick = { viewAsMarkdown }
274+ role = "menuitem"
275+ >
276+ < span > View as plain text</ span >
277+ </ button >
278+ </ li >
279+ < li className = { styles . item } >
280+ < button
281+ className = { styles . actionButton }
282+ onClick = { openInChatGpt }
283+ role = "menuitem"
284+ >
285+ < span > Open in ChatGPT</ span >
286+ </ button >
287+ </ li >
288+ </ ul >
289+ </ div >
290+ ) }
291+ </ div >
292+ ) ;
293+ }
294+
295+ export default CopyMarkdown ;
0 commit comments