Skip to content
This repository has been archived by the owner on Jan 2, 2025. It is now read-only.

Commit

Permalink
added export markdown functionality to documents the user is an autho…
Browse files Browse the repository at this point in the history
…r of
  • Loading branch information
iskaktoltay committed Jul 10, 2024
1 parent a827011 commit 9856a42
Show file tree
Hide file tree
Showing 10 changed files with 192 additions and 11 deletions.
29 changes: 29 additions & 0 deletions frontend/apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
BrowserWindow,
Menu,
app,
dialog,
globalShortcut,
ipcMain,
nativeTheme,
Expand All @@ -13,6 +14,7 @@ import {
import contextMenu from 'electron-context-menu'
import log from 'electron-log/main'
import squirrelStartup from 'electron-squirrel-startup'
import fs from 'fs'
import path from 'node:path'
import {
handleSecondInstance,
Expand Down Expand Up @@ -127,6 +129,33 @@ ipcMain.handle('dark-mode:system', () => {
})

ipcMain.on('save-file', saveCidAsFile)
ipcMain.on('export-document', async (_event, args) => {
const {title, markdown} = args
const camelTitle = title
.split(' ')
.map(
(word: string) =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
)
.join('')
const {filePath} = await dialog.showSaveDialog({
title: 'Save Markdown',
defaultPath: path.join(__dirname, camelTitle + '.md'),
buttonLabel: 'Save',
filters: [{name: 'Markdown Files', extensions: ['md']}],
})

if (filePath) {
fs.writeFile(filePath, markdown, (err) => {
if (err) {
console.error('Error saving file:', err)
return
}
console.log('File successfully saved:', filePath)
})
}
})

ipcMain.on('open-external-link', (_event, linkUrl) => {
shell.openExternal(linkUrl)
})
Expand Down
3 changes: 3 additions & 0 deletions frontend/apps/desktop/src/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,9 @@ function MainApp({
saveCidAsFile={async (cid: string, name: string) => {
ipc.send?.('save-file', {cid, name})
}}
exportDocument={async (title: string, markdown: string) => {
ipc.send?.('export-document', {title, markdown})
}}
windowUtils={windowUtils}
darkMode={darkMode}
>
Expand Down
4 changes: 4 additions & 0 deletions frontend/packages/app/app-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type AppContext = {
externalOpen: (url: string) => Promise<void>
windowUtils: WindowUtils
saveCidAsFile: (cid: string, name: string) => Promise<void>
exportDocument: (title: string, markdown: string) => Promise<void>
}

const AppContext = createContext<AppContext | null>(null)
Expand All @@ -32,6 +33,7 @@ export function AppContextProvider({
externalOpen,
windowUtils,
saveCidAsFile,
exportDocument,
darkMode,
}: {
children: ReactNode
Expand All @@ -42,6 +44,7 @@ export function AppContextProvider({
externalOpen: (url: string) => Promise<void>
windowUtils: WindowUtils
saveCidAsFile: (cid: string, name: string) => Promise<void>
exportDocument: (title: string, markdown: string) => Promise<void>
darkMode: boolean
}) {
const appCtx = useMemo(
Expand All @@ -54,6 +57,7 @@ export function AppContextProvider({
externalOpen,
windowUtils,
saveCidAsFile,
exportDocument,
}),
[],
)
Expand Down
37 changes: 37 additions & 0 deletions frontend/packages/app/components/export-doc-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {HMBlockNode, toHMBlock} from '@mintter/shared'
import {Button, Tooltip} from '@mintter/ui'
import {Download} from '@tamagui/lucide-icons'
import {useAppContext} from '../app-context'
import {usePublication} from '../models/documents'
import {convertBlocksToMarkdown} from '../utils/blocks-to-markdown'

export const ExportDocButton = ({
docId,
version,
}: {
docId: string | undefined
version: string | undefined
}) => {
const pub = usePublication({id: docId, version: version})
const title = pub.data?.document?.title || 'document'
const {exportDocument} = useAppContext()
return (
<>
<Tooltip content={'Export Document to Markdown'}>
<Button
size="$2"
theme="blue"
onPress={async () => {
const blocks: HMBlockNode[] | undefined =
pub.data?.document?.children
const editorBlocks = toHMBlock(blocks)
exportDocument(title, await convertBlocksToMarkdown(editorBlocks))
}}
icon={Download}
>
Export
</Button>
</Tooltip>
</>
)
}
5 changes: 5 additions & 0 deletions frontend/packages/app/components/variants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import CommitDraftButton from './commit-draft-button'
import {useAppDialog} from './dialog'
import DiscardDraftButton from './discard-draft-button'
import {EditDocButton, useEditDraft} from './edit-doc-button'
import {ExportDocButton} from './export-doc-button'
import {FormInput} from './form-input'
import {FormErrors, FormField} from './forms'
import {SelectInput} from './select-input'
Expand Down Expand Up @@ -569,6 +570,7 @@ export function PublicationVariants({route}: {route: PublicationRoute}) {
(item) => item.group?.id === groupVariant.groupId,
)
const showEditButton = isAuthorVariantEditable || isGroupVariantEditable
const showExportButton = pubOwner === myAccount.data?.id
const realVariants = variants || realAuthorVariants
return (
<>
Expand Down Expand Up @@ -637,6 +639,9 @@ export function PublicationVariants({route}: {route: PublicationRoute}) {
baseVersion={route.versionId}
/>
)}
{showExportButton && (
<ExportDocButton docId={route.documentId} version={route.versionId} />
)}
{renameDialog.content}
</>
)
Expand Down
94 changes: 94 additions & 0 deletions frontend/packages/app/utils/blocks-to-markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {HMBlock} from '@mintter/shared'
import rehypeParse from 'rehype-parse'
import rehypeRemark from 'rehype-remark'
import remarkGfm from 'remark-gfm'
import remarkStringify from 'remark-stringify'
import {unified} from 'unified'

function applyStyles(text, styles) {
if (styles.bold) text = `<strong>${text}</strong>`
if (styles.italic) text = `<em>${text}</em>`
if (styles.strike) text = `<del>${text}</del>`
if (styles.underline) text = `<u>${text}</u>`
return text
}

function convertContentItemToHtml(contentItem) {
let text = contentItem.text || ''
const {styles = {}} = contentItem

text = applyStyles(text, styles)

if (contentItem.type === 'link') {
const linkText = applyStyles(
contentItem.content[0].text,
contentItem.content[0].styles || {},
)
return `<a href="${contentItem.href}">${linkText}</a>`
} else {
return text
}
}

function convertBlockToHtml(block) {
let childrenHtml = ''
if (block.children) {
const childrenContent = block.children.map(convertBlockToHtml).join('\n')
if (block.props.childrenType === 'ul') {
childrenHtml = `<ul>${childrenContent}</ul>`
} else if (block.props.childrenType === 'ol') {
childrenHtml = `<ol start="${
block.props.start || 1
}">${childrenContent}</ol>`
} else {
childrenHtml = childrenContent
}
}

switch (block.type) {
case 'heading':
return `<h${block.props.level}>${block.content
.map(convertContentItemToHtml)
.join('')}</h${block.props.level}>\n${childrenHtml}`
case 'paragraph':
return `<p>${block.content
.map(convertContentItemToHtml)
.join('')}</p>\n${childrenHtml}`
case 'image':
return `<img src="${block.props.url}" alt="${block.content
.map((contentItem) => contentItem.text)
.join('')}" title="${block.props.name}">\n${childrenHtml}`
case 'codeBlock':
return `<pre><code class="language-${
block.props.language || 'plaintext'
}">${block.content
.map((contentItem) => contentItem.text)
.join('\n')}</code></pre>\n${childrenHtml}`
case 'video':
return `<p>![${block.props.name}](${block.props.url} "width=${block.props.width}")</p>\n${childrenHtml}`
case 'file':
return `<p>[${block.props.name}](${block.props.url} "size=${block.props.size}")</p>\n${childrenHtml}`
default:
return block.content
? block.content.map(convertContentItemToHtml).join('') +
`\n${childrenHtml}`
: childrenHtml
}
}

function convertBlocksToHtml(blocks) {
const htmlContent: string = blocks
.map((block) => convertBlockToHtml(block))
.join('\n\n')
return htmlContent
}

export async function convertBlocksToMarkdown(blocks: HMBlock[]) {
const markdownFile = await unified()
.use(rehypeParse, {fragment: true})
.use(rehypeRemark)
.use(remarkGfm)
.use(remarkStringify)
.process(convertBlocksToHtml(blocks))
return markdownFile.value as string
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export const ParagraphBlockContent = createTipTapBlock({
tag: 'p',
priority: 200,
node: 'paragraph',
getAttrs: (node) => {
// Don't match if has image (for markdown parse)
if (node.childNodes.length > 0 && node.childNodes[0].nodeName) {
const hasImage = node.childNodes[0].nodeName === 'IMG'
return hasImage ? false : {}
}
return null
},
},
]
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,7 @@ export const BlockGroup = Node.create<{
if (typeof element === 'string') {
return false
}
return (
element.getAttribute('data-node-type') === 'blockGroup' &&
element.getAttribute('data-list-type') === 'ul' &&
null
)
return {listType: 'ul'}
},
priority: 200,
},
Expand All @@ -98,11 +94,7 @@ export const BlockGroup = Node.create<{
if (typeof element === 'string') {
return false
}
return (
element.getAttribute('data-node-type') === 'blockGroup' &&
element.getAttribute('data-list-type') === 'ol' &&
null
)
return {listType: 'ol', start: element.getAttribute('start')}
},
priority: 200,
},
Expand Down
6 changes: 6 additions & 0 deletions frontend/packages/editor/src/media-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ export const MediaRender: React.FC<RenderProps> = ({

useEffect(() => {
if (!uploading && hasSrc) {
if (block.props.src.startsWith('ipfs')) {
editor.updateBlock(block, {
props: {url: block.props.src, src: ''},
})
return
}
setUploading(true)

client.webImporting.importWebFile
Expand Down
5 changes: 4 additions & 1 deletion frontend/packages/editor/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ export const hmBlockSchema: BlockSchema = {
paragraph: defaultBlockSchema.paragraph,
// heading: defaultBlockSchema.heading,
heading: {
propSchema: {},
propSchema: {
...defaultProps,
level: {default: '1'},
},
node: HMHeadingBlockContent,
},
image: ImageBlock,
Expand Down

0 comments on commit 9856a42

Please sign in to comment.