Skip to content

Commit

Permalink
Merge pull request #225 from ssshooter/feat/export-svg
Browse files Browse the repository at this point in the history
Yeah! Finally we can export image!
  • Loading branch information
SSShooter authored Sep 28, 2023
2 parents 4ed9421 + e5f018b commit e559887
Show file tree
Hide file tree
Showing 9 changed files with 259 additions and 11 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mind-elixir",
"version": "3.1.4",
"version": "3.2.0",
"type": "module",
"description": "Mind elixir is a free open source mind map core.",
"keywords": [
Expand Down
29 changes: 26 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,12 @@ Mind elixir is a free open source mind map core.
- High performance
- Framework agnostic
- Pluginable
- Export as SVG or PNG
- Build-in drag and drop / node edit plugin
- Summarize nodes
- Undo / Redo
- Styling your node with CSS
- Undo / Redo
- Efficient shortcuts

<details>
<summary>Table of Contents</summary>
Expand All @@ -50,7 +52,8 @@ Mind elixir is a free open source mind map core.
- [Event Handling](#event-handling)
- [Data Export And Import](#data-export-and-import)
- [Operation Guards](#operation-guards)
- [Methods](#methods)
- [Export as a Image](#export-as-a-image)
- [APIs](#apis)
- [Theme](#theme)
- [Shortcuts](#shortcuts)
- [Not only core](#not-only-core)
Expand Down Expand Up @@ -253,7 +256,27 @@ let mind = new MindElixir({
})
```

## Methods
## Export as a Image

```typescript
import { exportPng } from './plugin/exportImage'

const mind = {
/** mind elixir instance */
}
const downloadPng = async () => {
const blob = await mind.exportPng() // Get a Blob!
if (!blob) return
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'filename.png'
a.click()
URL.revokeObjectURL(url)
}
```

## APIs

https://github.com/ssshooter/mind-elixir-core/blob/master/api/mind-elixir.api.md

Expand Down
15 changes: 15 additions & 0 deletions src/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import example2 from './exampleData/2'
import example3 from './exampleData/3'
import type { Options, MindElixirData, MindElixirInstance } from './types/index'
import type { Operation } from './utils/pubsub'
import { exportPng } from './plugin/exportImage'

interface Window {
m: MindElixirInstance
M: MindElixirCtor
E: typeof MindElixir.E
downloadPng: typeof downloadPng
}

declare let window: Window
Expand Down Expand Up @@ -99,6 +101,19 @@ mind.bus.addListener('selectNode', node => {
mind.bus.addListener('expandNode', node => {
console.log('expandNode: ', node)
})

const downloadPng = async () => {
const blob = await mind.exportPng()
if (!blob) return
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'filename.png'
a.click()
URL.revokeObjectURL(url)
}

window.downloadPng = downloadPng
window.m = mind
// window.m2 = mind2
window.M = MindElixir
Expand Down
2 changes: 1 addition & 1 deletion src/exampleData/1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ const aboutMindElixir: MindElixirData = {
expanded: true,
children: [
{
topic: 'Save button in the top-right corner',
topic: 'Save button in the top-right corner',
id: 'bd42e619051878b3',
expanded: true,
children: [],
Expand Down
12 changes: 8 additions & 4 deletions src/index.less
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
me-tpc {
display: block;
font-size: 25px;
line-height: 1.2em;
color: var(--root-color);
padding: 10px var(--gap);
border-radius: var(--root-radius);
Expand Down Expand Up @@ -120,10 +121,10 @@
border-radius: 3px;
color: var(--color);
pointer-events: all;
max-width: 800px;
max-width: 35em;
white-space: pre-wrap;
padding: var(--topic-padding);
line-height: 1.2; // assure the line-height consistency between different languages
line-height: 1.2em; // assure the line-height consistency between different languages
& > div,
& > span,
& > img {
Expand Down Expand Up @@ -233,7 +234,7 @@
color: var(--color);
background-color: var(--bgcolor);
width: max-content; // let words expand the div and keep max length at the same time
max-width: 800px;
max-width: 35em;
z-index: 11;
direction: ltr;
user-select: auto;
Expand Down Expand Up @@ -267,7 +268,7 @@
color: #276f86;
margin: 0px;
font-size: 12px;
line-height: 16px;
line-height: 1.3em;
margin-right: 3px;
margin-top: 2px;
}
Expand All @@ -276,6 +277,9 @@
display: inline-block;
direction: ltr;
margin-right: 10px;
span {
display: inline-block;
}
}

.mind-elixir-ghost {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ MindElixir.DARK_THEME = DARK_THEME
* @memberof MindElixir
* @static
*/
MindElixir.version = '3.1.4'
MindElixir.version = '3.2.0'
/**
* @function
* @memberof MindElixir
Expand Down
2 changes: 2 additions & 0 deletions src/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as interact from './interact'
import * as nodeOperation from './nodeOperation'
import * as customLink from './customLink'
import * as summaryOperation from './summary'
import * as exportImage from './plugin/exportImage'

type Operations = keyof typeof nodeOperation
type NodeOperation = Record<Operations, ReturnType<typeof beforeHook>>
Expand Down Expand Up @@ -62,6 +63,7 @@ const methods = {
...(nodeOperationHooked as NodeOperation),
...customLink,
...summaryOperation,
...exportImage,
init(this: MindElixirInstance, data: MindElixirData) {
if (!data || !data.nodeData) return new Error('MindElixir: `data` is required')
if (data.direction !== undefined) {
Expand Down
205 changes: 205 additions & 0 deletions src/plugin/exportImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import type { Topic } from '../types/dom'
import type { MindElixirInstance } from '../types'
import { setAttributes } from '../utils'
import { getOffsetLT } from '../utils'

function createSvgDom(height: string, width: string) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
setAttributes(svg, {
version: '1.1',
xmlns: 'http://www.w3.org/2000/svg',
height,
width,
})
return svg
}

function lineHightToPadding(lineHeight: string, fontSize: string) {
return (parseInt(lineHeight) - parseInt(fontSize)) / 2
}

function generateSvgText(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: number, y: number) {
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
const content = tpc.childNodes[0].textContent
const lines = content!.split('\n')
lines.forEach((line, index) => {
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text')
setAttributes(text, {
x: x + parseInt(tpcStyle.paddingLeft) + '',
y:
y +
parseInt(tpcStyle.paddingTop) +
lineHightToPadding(tpcStyle.lineHeight, tpcStyle.fontSize) * (index + 1) +
parseFloat(tpcStyle.fontSize) * (index + 1) +
'',
'text-anchor': 'start',
'font-family': tpcStyle.fontFamily,
'font-size': `${tpcStyle.fontSize}`,
'font-weight': `${tpcStyle.fontWeight}`,
fill: `${tpcStyle.color}`,
})
text.innerHTML = line
g.appendChild(text)
})
return g
}

function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: number, y: number) {
const content = tpc.childNodes[0].textContent!
const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject')
setAttributes(foreignObject, {
x: x + parseInt(tpcStyle.paddingLeft) + '',
y: y + parseInt(tpcStyle.paddingTop) + '',
width: tpcStyle.width,
height: tpcStyle.height,
})
const div = document.createElement('div')
setAttributes(div, {
xmlns: 'http://www.w3.org/1999/xhtml',
style: `font-family: ${tpcStyle.fontFamily}; font-size: ${tpcStyle.fontSize}; font-weight: ${tpcStyle.fontWeight}; color: ${tpcStyle.color}; white-space: pre-wrap;`,
})
div.innerHTML = content
foreignObject.appendChild(div)
return foreignObject
}

function convertDivToSvg(mei: MindElixirInstance, tpc: HTMLElement, useForeignObject = false) {
const tpcStyle = getComputedStyle(tpc)
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, tpc)

const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
setAttributes(bg, {
x: x + '',
y: y + '',
rx: tpcStyle.borderRadius,
ry: tpcStyle.borderRadius,
width: tpcStyle.width,
height: tpcStyle.height,
fill: tpcStyle.backgroundColor,
stroke: tpcStyle.borderColor,
'stroke-width': tpcStyle.borderWidth,
})
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
g.appendChild(bg)
let text: SVGGElement | null
if (useForeignObject) {
text = generateSvgTextUsingForeignObject(tpc, tpcStyle, x, y)
} else text = generateSvgText(tpc, tpcStyle, x, y)
g.appendChild(text)
return g
}

function convertAToSvg(mei: MindElixirInstance, a: HTMLAnchorElement) {
const aStyle = getComputedStyle(a)
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, a)
const svgA = document.createElementNS('http://www.w3.org/2000/svg', 'a')
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text')
setAttributes(text, {
x: x + '',
y: y + parseInt(aStyle.fontSize) + '',
'text-anchor': 'start',
'font-family': aStyle.fontFamily,
'font-size': `${aStyle.fontSize}`,
'font-weight': `${aStyle.fontWeight}`,
fill: `${aStyle.color}`,
})
text.innerHTML = a.textContent!
svgA.appendChild(text)
svgA.setAttribute('href', a.href)
return svgA
}

const padding = 100

const head = `<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">`

const generateSvg = (mei: MindElixirInstance) => {
const mapDiv = mei.nodes
const height = mapDiv.offsetHeight + padding * 2
const width = mapDiv.offsetWidth + padding * 2
const svg = createSvgDom(height + 'px', width + 'px')
const g = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
const bgColor = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
setAttributes(bgColor, {
x: '0',
y: '0',
width: `${width}`,
height: `${height}`,
fill: mei.theme.cssVar['--bgcolor'] as string,
})
svg.appendChild(bgColor)
mapDiv.querySelectorAll('.subLines').forEach(item => {
const clone = item.cloneNode(true) as SVGSVGElement
const { offsetLeft, offsetTop } = getOffsetLT(mapDiv, item.parentElement as HTMLElement)
clone.setAttribute('x', `${offsetLeft}`)
clone.setAttribute('y', `${offsetTop}`)
g.appendChild(clone)
})

const mainLine = mapDiv.querySelector('.lines')?.cloneNode(true)
mainLine && g.appendChild(mainLine)
const topiclinks = mapDiv.querySelector('.topiclinks')?.cloneNode(true)
topiclinks && g.appendChild(topiclinks)
const summaries = mapDiv.querySelector('.summary')?.cloneNode(true)
summaries && g.appendChild(summaries)

mapDiv.querySelectorAll('me-tpc').forEach(tpc => {
g.appendChild(convertDivToSvg(mei, tpc as Topic, true))
})
mapDiv.querySelectorAll('.tags > span').forEach(tag => {
g.appendChild(convertDivToSvg(mei, tag as HTMLElement))
})
mapDiv.querySelectorAll('.icons > span').forEach(icon => {
g.appendChild(convertDivToSvg(mei, icon as HTMLElement))
})
mapDiv.querySelectorAll('.hyper-link').forEach(hl => {
g.appendChild(convertAToSvg(mei, hl as HTMLAnchorElement))
})
setAttributes(g, {
x: padding + '',
y: padding + '',
overflow: 'visible',
})
svg.appendChild(g)
return head + svg.outerHTML
}

function blobToUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = evt => {
resolve(evt.target!.result as string)
}
reader.onerror = err => {
reject(err)
}
reader.readAsDataURL(blob)
})
}

export const exportSvg = function (this: MindElixirInstance) {
const svgString = generateSvg(this)
const blob = new Blob([svgString], { type: 'image/svg+xml' })
return blob
}

export const exportPng = async function (this: MindElixirInstance): Promise<Blob | null> {
const svgString = generateSvg(this)
const blob = new Blob([svgString], { type: 'image/svg+xml' })
// use base64 to bypass canvas taint
const url = await blobToUrl(blob)
return new Promise((resolve, reject) => {
const img = new Image()
img.setAttribute('crossOrigin', 'anonymous')
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext('2d')!
ctx.drawImage(img, 0, 0)
canvas.toBlob(resolve, 'image/png', 1)
}
img.src = url
img.onerror = reject
})
}
1 change: 0 additions & 1 deletion src/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ export const shapeTpc = function (tpc: Topic, nodeObj: NodeObj) {
linkContainer.href = nodeObj.hyperLink
tpc.appendChild(linkContainer)
tpc.linkContainer = linkContainer
console.log(linkContainer)
} else if (tpc.linkContainer) {
tpc.linkContainer.remove()
tpc.linkContainer = null
Expand Down

0 comments on commit e559887

Please sign in to comment.