Skip to content

Commit e559887

Browse files
authored
Merge pull request #225 from ssshooter/feat/export-svg
Yeah! Finally we can export image!
2 parents 4ed9421 + e5f018b commit e559887

File tree

9 files changed

+259
-11
lines changed

9 files changed

+259
-11
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mind-elixir",
3-
"version": "3.1.4",
3+
"version": "3.2.0",
44
"type": "module",
55
"description": "Mind elixir is a free open source mind map core.",
66
"keywords": [

readme.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ Mind elixir is a free open source mind map core.
3131
- High performance
3232
- Framework agnostic
3333
- Pluginable
34+
- Export as SVG or PNG
3435
- Build-in drag and drop / node edit plugin
3536
- Summarize nodes
36-
- Undo / Redo
3737
- Styling your node with CSS
38+
- Undo / Redo
39+
- Efficient shortcuts
3840

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

256-
## Methods
259+
## Export as a Image
260+
261+
```typescript
262+
import { exportPng } from './plugin/exportImage'
263+
264+
const mind = {
265+
/** mind elixir instance */
266+
}
267+
const downloadPng = async () => {
268+
const blob = await mind.exportPng() // Get a Blob!
269+
if (!blob) return
270+
const url = URL.createObjectURL(blob)
271+
const a = document.createElement('a')
272+
a.href = url
273+
a.download = 'filename.png'
274+
a.click()
275+
URL.revokeObjectURL(url)
276+
}
277+
```
278+
279+
## APIs
257280

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

src/dev.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import example2 from './exampleData/2'
55
import example3 from './exampleData/3'
66
import type { Options, MindElixirData, MindElixirInstance } from './types/index'
77
import type { Operation } from './utils/pubsub'
8+
import { exportPng } from './plugin/exportImage'
89

910
interface Window {
1011
m: MindElixirInstance
1112
M: MindElixirCtor
1213
E: typeof MindElixir.E
14+
downloadPng: typeof downloadPng
1315
}
1416

1517
declare let window: Window
@@ -99,6 +101,19 @@ mind.bus.addListener('selectNode', node => {
99101
mind.bus.addListener('expandNode', node => {
100102
console.log('expandNode: ', node)
101103
})
104+
105+
const downloadPng = async () => {
106+
const blob = await mind.exportPng()
107+
if (!blob) return
108+
const url = URL.createObjectURL(blob)
109+
const a = document.createElement('a')
110+
a.href = url
111+
a.download = 'filename.png'
112+
a.click()
113+
URL.revokeObjectURL(url)
114+
}
115+
116+
window.downloadPng = downloadPng
102117
window.m = mind
103118
// window.m2 = mind2
104119
window.M = MindElixir

src/exampleData/1.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ const aboutMindElixir: MindElixirData = {
222222
expanded: true,
223223
children: [
224224
{
225-
topic: 'Save button in the top-right corner',
225+
topic: 'Save button in the top-right corner',
226226
id: 'bd42e619051878b3',
227227
expanded: true,
228228
children: [],

src/index.less

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
me-tpc {
6767
display: block;
6868
font-size: 25px;
69+
line-height: 1.2em;
6970
color: var(--root-color);
7071
padding: 10px var(--gap);
7172
border-radius: var(--root-radius);
@@ -120,10 +121,10 @@
120121
border-radius: 3px;
121122
color: var(--color);
122123
pointer-events: all;
123-
max-width: 800px;
124+
max-width: 35em;
124125
white-space: pre-wrap;
125126
padding: var(--topic-padding);
126-
line-height: 1.2; // assure the line-height consistency between different languages
127+
line-height: 1.2em; // assure the line-height consistency between different languages
127128
& > div,
128129
& > span,
129130
& > img {
@@ -233,7 +234,7 @@
233234
color: var(--color);
234235
background-color: var(--bgcolor);
235236
width: max-content; // let words expand the div and keep max length at the same time
236-
max-width: 800px;
237+
max-width: 35em;
237238
z-index: 11;
238239
direction: ltr;
239240
user-select: auto;
@@ -267,7 +268,7 @@
267268
color: #276f86;
268269
margin: 0px;
269270
font-size: 12px;
270-
line-height: 16px;
271+
line-height: 1.3em;
271272
margin-right: 3px;
272273
margin-top: 2px;
273274
}
@@ -276,6 +277,9 @@
276277
display: inline-block;
277278
direction: ltr;
278279
margin-right: 10px;
280+
span {
281+
display: inline-block;
282+
}
279283
}
280284

281285
.mind-elixir-ghost {

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ MindElixir.DARK_THEME = DARK_THEME
126126
* @memberof MindElixir
127127
* @static
128128
*/
129-
MindElixir.version = '3.1.4'
129+
MindElixir.version = '3.2.0'
130130
/**
131131
* @function
132132
* @memberof MindElixir

src/methods.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as interact from './interact'
1515
import * as nodeOperation from './nodeOperation'
1616
import * as customLink from './customLink'
1717
import * as summaryOperation from './summary'
18+
import * as exportImage from './plugin/exportImage'
1819

1920
type Operations = keyof typeof nodeOperation
2021
type NodeOperation = Record<Operations, ReturnType<typeof beforeHook>>
@@ -62,6 +63,7 @@ const methods = {
6263
...(nodeOperationHooked as NodeOperation),
6364
...customLink,
6465
...summaryOperation,
66+
...exportImage,
6567
init(this: MindElixirInstance, data: MindElixirData) {
6668
if (!data || !data.nodeData) return new Error('MindElixir: `data` is required')
6769
if (data.direction !== undefined) {

src/plugin/exportImage.ts

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import type { Topic } from '../types/dom'
2+
import type { MindElixirInstance } from '../types'
3+
import { setAttributes } from '../utils'
4+
import { getOffsetLT } from '../utils'
5+
6+
function createSvgDom(height: string, width: string) {
7+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
8+
setAttributes(svg, {
9+
version: '1.1',
10+
xmlns: 'http://www.w3.org/2000/svg',
11+
height,
12+
width,
13+
})
14+
return svg
15+
}
16+
17+
function lineHightToPadding(lineHeight: string, fontSize: string) {
18+
return (parseInt(lineHeight) - parseInt(fontSize)) / 2
19+
}
20+
21+
function generateSvgText(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: number, y: number) {
22+
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
23+
const content = tpc.childNodes[0].textContent
24+
const lines = content!.split('\n')
25+
lines.forEach((line, index) => {
26+
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text')
27+
setAttributes(text, {
28+
x: x + parseInt(tpcStyle.paddingLeft) + '',
29+
y:
30+
y +
31+
parseInt(tpcStyle.paddingTop) +
32+
lineHightToPadding(tpcStyle.lineHeight, tpcStyle.fontSize) * (index + 1) +
33+
parseFloat(tpcStyle.fontSize) * (index + 1) +
34+
'',
35+
'text-anchor': 'start',
36+
'font-family': tpcStyle.fontFamily,
37+
'font-size': `${tpcStyle.fontSize}`,
38+
'font-weight': `${tpcStyle.fontWeight}`,
39+
fill: `${tpcStyle.color}`,
40+
})
41+
text.innerHTML = line
42+
g.appendChild(text)
43+
})
44+
return g
45+
}
46+
47+
function generateSvgTextUsingForeignObject(tpc: HTMLElement, tpcStyle: CSSStyleDeclaration, x: number, y: number) {
48+
const content = tpc.childNodes[0].textContent!
49+
const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject')
50+
setAttributes(foreignObject, {
51+
x: x + parseInt(tpcStyle.paddingLeft) + '',
52+
y: y + parseInt(tpcStyle.paddingTop) + '',
53+
width: tpcStyle.width,
54+
height: tpcStyle.height,
55+
})
56+
const div = document.createElement('div')
57+
setAttributes(div, {
58+
xmlns: 'http://www.w3.org/1999/xhtml',
59+
style: `font-family: ${tpcStyle.fontFamily}; font-size: ${tpcStyle.fontSize}; font-weight: ${tpcStyle.fontWeight}; color: ${tpcStyle.color}; white-space: pre-wrap;`,
60+
})
61+
div.innerHTML = content
62+
foreignObject.appendChild(div)
63+
return foreignObject
64+
}
65+
66+
function convertDivToSvg(mei: MindElixirInstance, tpc: HTMLElement, useForeignObject = false) {
67+
const tpcStyle = getComputedStyle(tpc)
68+
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, tpc)
69+
70+
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
71+
setAttributes(bg, {
72+
x: x + '',
73+
y: y + '',
74+
rx: tpcStyle.borderRadius,
75+
ry: tpcStyle.borderRadius,
76+
width: tpcStyle.width,
77+
height: tpcStyle.height,
78+
fill: tpcStyle.backgroundColor,
79+
stroke: tpcStyle.borderColor,
80+
'stroke-width': tpcStyle.borderWidth,
81+
})
82+
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
83+
g.appendChild(bg)
84+
let text: SVGGElement | null
85+
if (useForeignObject) {
86+
text = generateSvgTextUsingForeignObject(tpc, tpcStyle, x, y)
87+
} else text = generateSvgText(tpc, tpcStyle, x, y)
88+
g.appendChild(text)
89+
return g
90+
}
91+
92+
function convertAToSvg(mei: MindElixirInstance, a: HTMLAnchorElement) {
93+
const aStyle = getComputedStyle(a)
94+
const { offsetLeft: x, offsetTop: y } = getOffsetLT(mei.nodes, a)
95+
const svgA = document.createElementNS('http://www.w3.org/2000/svg', 'a')
96+
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text')
97+
setAttributes(text, {
98+
x: x + '',
99+
y: y + parseInt(aStyle.fontSize) + '',
100+
'text-anchor': 'start',
101+
'font-family': aStyle.fontFamily,
102+
'font-size': `${aStyle.fontSize}`,
103+
'font-weight': `${aStyle.fontWeight}`,
104+
fill: `${aStyle.color}`,
105+
})
106+
text.innerHTML = a.textContent!
107+
svgA.appendChild(text)
108+
svgA.setAttribute('href', a.href)
109+
return svgA
110+
}
111+
112+
const padding = 100
113+
114+
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">`
115+
116+
const generateSvg = (mei: MindElixirInstance) => {
117+
const mapDiv = mei.nodes
118+
const height = mapDiv.offsetHeight + padding * 2
119+
const width = mapDiv.offsetWidth + padding * 2
120+
const svg = createSvgDom(height + 'px', width + 'px')
121+
const g = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
122+
const bgColor = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
123+
setAttributes(bgColor, {
124+
x: '0',
125+
y: '0',
126+
width: `${width}`,
127+
height: `${height}`,
128+
fill: mei.theme.cssVar['--bgcolor'] as string,
129+
})
130+
svg.appendChild(bgColor)
131+
mapDiv.querySelectorAll('.subLines').forEach(item => {
132+
const clone = item.cloneNode(true) as SVGSVGElement
133+
const { offsetLeft, offsetTop } = getOffsetLT(mapDiv, item.parentElement as HTMLElement)
134+
clone.setAttribute('x', `${offsetLeft}`)
135+
clone.setAttribute('y', `${offsetTop}`)
136+
g.appendChild(clone)
137+
})
138+
139+
const mainLine = mapDiv.querySelector('.lines')?.cloneNode(true)
140+
mainLine && g.appendChild(mainLine)
141+
const topiclinks = mapDiv.querySelector('.topiclinks')?.cloneNode(true)
142+
topiclinks && g.appendChild(topiclinks)
143+
const summaries = mapDiv.querySelector('.summary')?.cloneNode(true)
144+
summaries && g.appendChild(summaries)
145+
146+
mapDiv.querySelectorAll('me-tpc').forEach(tpc => {
147+
g.appendChild(convertDivToSvg(mei, tpc as Topic, true))
148+
})
149+
mapDiv.querySelectorAll('.tags > span').forEach(tag => {
150+
g.appendChild(convertDivToSvg(mei, tag as HTMLElement))
151+
})
152+
mapDiv.querySelectorAll('.icons > span').forEach(icon => {
153+
g.appendChild(convertDivToSvg(mei, icon as HTMLElement))
154+
})
155+
mapDiv.querySelectorAll('.hyper-link').forEach(hl => {
156+
g.appendChild(convertAToSvg(mei, hl as HTMLAnchorElement))
157+
})
158+
setAttributes(g, {
159+
x: padding + '',
160+
y: padding + '',
161+
overflow: 'visible',
162+
})
163+
svg.appendChild(g)
164+
return head + svg.outerHTML
165+
}
166+
167+
function blobToUrl(blob: Blob): Promise<string> {
168+
return new Promise((resolve, reject) => {
169+
const reader = new FileReader()
170+
reader.onload = evt => {
171+
resolve(evt.target!.result as string)
172+
}
173+
reader.onerror = err => {
174+
reject(err)
175+
}
176+
reader.readAsDataURL(blob)
177+
})
178+
}
179+
180+
export const exportSvg = function (this: MindElixirInstance) {
181+
const svgString = generateSvg(this)
182+
const blob = new Blob([svgString], { type: 'image/svg+xml' })
183+
return blob
184+
}
185+
186+
export const exportPng = async function (this: MindElixirInstance): Promise<Blob | null> {
187+
const svgString = generateSvg(this)
188+
const blob = new Blob([svgString], { type: 'image/svg+xml' })
189+
// use base64 to bypass canvas taint
190+
const url = await blobToUrl(blob)
191+
return new Promise((resolve, reject) => {
192+
const img = new Image()
193+
img.setAttribute('crossOrigin', 'anonymous')
194+
img.onload = () => {
195+
const canvas = document.createElement('canvas')
196+
canvas.width = img.width
197+
canvas.height = img.height
198+
const ctx = canvas.getContext('2d')!
199+
ctx.drawImage(img, 0, 0)
200+
canvas.toBlob(resolve, 'image/png', 1)
201+
}
202+
img.src = url
203+
img.onerror = reject
204+
})
205+
}

src/utils/dom.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ export const shapeTpc = function (tpc: Topic, nodeObj: NodeObj) {
4545
linkContainer.href = nodeObj.hyperLink
4646
tpc.appendChild(linkContainer)
4747
tpc.linkContainer = linkContainer
48-
console.log(linkContainer)
4948
} else if (tpc.linkContainer) {
5049
tpc.linkContainer.remove()
5150
tpc.linkContainer = null

0 commit comments

Comments
 (0)