mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-25 03:10:24 +03:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf0ba9f756 | ||
|
|
05f8034439 | ||
|
|
1d1186c901 | ||
|
|
641a26cdf7 | ||
|
|
5fd8e7e0e9 | ||
|
|
d926ca5f71 | ||
|
|
b69722c3b5 | ||
|
|
c9aa1c979f |
@@ -215,14 +215,16 @@ class ExportFormatter
|
|||||||
*/
|
*/
|
||||||
protected function containHtml(string $htmlContent): string
|
protected function containHtml(string $htmlContent): string
|
||||||
{
|
{
|
||||||
$imageTagsOutput = [];
|
// Replace embed tags with images
|
||||||
preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
|
$htmlContent = preg_replace("/<embed (.*?)>/i", '<img $1>', $htmlContent);
|
||||||
|
|
||||||
// Replace image src with base64 encoded image strings
|
// Replace image & embed src attributes with base64 encoded data strings
|
||||||
|
$imageTagsOutput = [];
|
||||||
|
preg_match_all("/<img .*?src=['\"](.*?)['\"].*?>/i", $htmlContent, $imageTagsOutput);
|
||||||
if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
|
if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
|
||||||
foreach ($imageTagsOutput[0] as $index => $imgMatch) {
|
foreach ($imageTagsOutput[0] as $index => $imgMatch) {
|
||||||
$oldImgTagString = $imgMatch;
|
$oldImgTagString = $imgMatch;
|
||||||
$srcString = $imageTagsOutput[2][$index];
|
$srcString = $imageTagsOutput[1][$index];
|
||||||
$imageEncoded = $this->imageService->imageUriToBase64($srcString);
|
$imageEncoded = $this->imageService->imageUriToBase64($srcString);
|
||||||
if ($imageEncoded === null) {
|
if ($imageEncoded === null) {
|
||||||
$imageEncoded = $srcString;
|
$imageEncoded = $srcString;
|
||||||
@@ -232,14 +234,13 @@ class ExportFormatter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replace any relative links with full system URL
|
||||||
$linksOutput = [];
|
$linksOutput = [];
|
||||||
preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $linksOutput);
|
preg_match_all("/<a .*href=['\"](.*?)['\"].*?>/i", $htmlContent, $linksOutput);
|
||||||
|
|
||||||
// Replace image src with base64 encoded image strings
|
|
||||||
if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
|
if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
|
||||||
foreach ($linksOutput[0] as $index => $linkMatch) {
|
foreach ($linksOutput[0] as $index => $linkMatch) {
|
||||||
$oldLinkString = $linkMatch;
|
$oldLinkString = $linkMatch;
|
||||||
$srcString = $linksOutput[2][$index];
|
$srcString = $linksOutput[1][$index];
|
||||||
if (strpos(trim($srcString), 'http') !== 0) {
|
if (strpos(trim($srcString), 'http') !== 0) {
|
||||||
$newSrcString = url($srcString);
|
$newSrcString = url($srcString);
|
||||||
$newLinkString = str_replace($srcString, $newSrcString, $oldLinkString);
|
$newLinkString = str_replace($srcString, $newSrcString, $oldLinkString);
|
||||||
@@ -248,7 +249,6 @@ class ExportFormatter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace any relative links with system domain
|
|
||||||
return $htmlContent;
|
return $htmlContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,6 @@ abstract class Controller extends BaseController
|
|||||||
*/
|
*/
|
||||||
protected function getImageValidationRules(): array
|
protected function getImageValidationRules(): array
|
||||||
{
|
{
|
||||||
return ['image_extension', 'mimes:jpeg,png,gif,webp', 'max:' . (config('app.upload_limit') * 1000)];
|
return ['image_extension', 'mimes:jpeg,png,gif,webp,svg', 'max:' . (config('app.upload_limit') * 1000)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,8 +76,11 @@ class DrawioImageController extends Controller
|
|||||||
return $this->jsonError('Image data could not be found');
|
return $this->jsonError('Image data could not be found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$isSvg = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'svg';
|
||||||
|
$uriPrefix = $isSvg ? 'data:image/svg+xml;base64,' : 'data:image/png;base64,';
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'content' => base64_encode($imageData),
|
'content' => $uriPrefix . base64_encode($imageData),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,7 +148,8 @@ class ImageRepo
|
|||||||
*/
|
*/
|
||||||
public function saveDrawing(string $base64Uri, int $uploadedTo): Image
|
public function saveDrawing(string $base64Uri, int $uploadedTo): Image
|
||||||
{
|
{
|
||||||
$name = 'Drawing-' . user()->id . '-' . time() . '.png';
|
$isSvg = strpos($base64Uri, 'data:image/svg+xml;') === 0;
|
||||||
|
$name = 'Drawing-' . user()->id . '-' . time() . ($isSvg ? '.svg' : '.png');
|
||||||
|
|
||||||
return $this->imageService->saveNewFromBase64Uri($base64Uri, $name, 'drawio', $uploadedTo);
|
return $this->imageService->saveNewFromBase64Uri($base64Uri, $name, 'drawio', $uploadedTo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class ImageService
|
|||||||
protected $image;
|
protected $image;
|
||||||
protected $fileSystem;
|
protected $fileSystem;
|
||||||
|
|
||||||
protected static $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
protected static $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ImageService constructor.
|
* ImageService constructor.
|
||||||
@@ -230,6 +230,14 @@ class ImageService
|
|||||||
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
|
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given image is an SVG image file.
|
||||||
|
*/
|
||||||
|
protected function isSvg(Image $image): bool
|
||||||
|
{
|
||||||
|
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'svg';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the given image and image data is apng.
|
* Check if the given image and image data is apng.
|
||||||
*/
|
*/
|
||||||
@@ -255,8 +263,8 @@ class ImageService
|
|||||||
*/
|
*/
|
||||||
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string
|
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string
|
||||||
{
|
{
|
||||||
// Do not resize GIF images where we're not cropping
|
// Do not resize GIF images where we're not cropping or SVG images.
|
||||||
if ($keepRatio && $this->isGif($image)) {
|
if (($keepRatio && $this->isGif($image)) || $this->isSvg($image)) {
|
||||||
return $this->getPublicUrl($image->path);
|
return $this->getPublicUrl($image->path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,8 @@ class MarkdownEditor {
|
|||||||
this.markdown = new MarkdownIt({html: true});
|
this.markdown = new MarkdownIt({html: true});
|
||||||
this.markdown.use(mdTasksLists, {label: true});
|
this.markdown.use(mdTasksLists, {label: true});
|
||||||
|
|
||||||
this.display = this.elem.querySelector('.markdown-display');
|
this.display = this.$refs.display;
|
||||||
|
this.input = this.$refs.input;
|
||||||
this.displayStylesLoaded = false;
|
|
||||||
this.input = this.elem.querySelector('textarea');
|
|
||||||
|
|
||||||
this.cm = null;
|
this.cm = null;
|
||||||
this.Code = null;
|
this.Code = null;
|
||||||
@@ -32,23 +30,13 @@ class MarkdownEditor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.onMarkdownScroll = this.onMarkdownScroll.bind(this);
|
this.onMarkdownScroll = this.onMarkdownScroll.bind(this);
|
||||||
|
|
||||||
const displayLoad = () => {
|
|
||||||
this.displayDoc = this.display.contentDocument;
|
|
||||||
this.init(cmLoadPromise);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.display.contentDocument.readyState === 'complete') {
|
|
||||||
displayLoad();
|
|
||||||
} else {
|
|
||||||
this.display.addEventListener('load', displayLoad.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
window.$events.emitPublic(this.elem, 'editor-markdown::setup', {
|
window.$events.emitPublic(this.elem, 'editor-markdown::setup', {
|
||||||
markdownIt: this.markdown,
|
markdownIt: this.markdown,
|
||||||
displayEl: this.display,
|
displayEl: this.display,
|
||||||
codeMirrorInstance: this.cm,
|
codeMirrorInstance: this.cm,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.init(cmLoadPromise);
|
||||||
}
|
}
|
||||||
|
|
||||||
init(cmLoadPromise) {
|
init(cmLoadPromise) {
|
||||||
@@ -56,17 +44,17 @@ class MarkdownEditor {
|
|||||||
let lastClick = 0;
|
let lastClick = 0;
|
||||||
|
|
||||||
// Prevent markdown display link click redirect
|
// Prevent markdown display link click redirect
|
||||||
this.displayDoc.addEventListener('click', event => {
|
this.display.addEventListener('click', event => {
|
||||||
let isDblClick = Date.now() - lastClick < 300;
|
const isDblClick = Date.now() - lastClick < 300;
|
||||||
|
|
||||||
let link = event.target.closest('a');
|
const link = event.target.closest('a');
|
||||||
if (link !== null) {
|
if (link !== null) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
window.open(link.getAttribute('href'));
|
window.open(link.getAttribute('href'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let drawing = event.target.closest('[drawio-diagram]');
|
const drawing = event.target.closest('[drawio-diagram]');
|
||||||
if (drawing !== null && isDblClick) {
|
if (drawing !== null && isDblClick) {
|
||||||
this.actionEditDrawing(drawing);
|
this.actionEditDrawing(drawing);
|
||||||
return;
|
return;
|
||||||
@@ -77,10 +65,10 @@ class MarkdownEditor {
|
|||||||
|
|
||||||
// Button actions
|
// Button actions
|
||||||
this.elem.addEventListener('click', event => {
|
this.elem.addEventListener('click', event => {
|
||||||
let button = event.target.closest('button[data-action]');
|
const button = event.target.closest('button[data-action]');
|
||||||
if (button === null) return;
|
if (button === null) return;
|
||||||
|
|
||||||
let action = button.getAttribute('data-action');
|
const action = button.getAttribute('data-action');
|
||||||
if (action === 'insertImage') this.actionInsertImage();
|
if (action === 'insertImage') this.actionInsertImage();
|
||||||
if (action === 'insertLink') this.actionShowLinkSelector();
|
if (action === 'insertLink') this.actionShowLinkSelector();
|
||||||
if (action === 'insertDrawing' && (event.ctrlKey || event.metaKey)) {
|
if (action === 'insertDrawing' && (event.ctrlKey || event.metaKey)) {
|
||||||
@@ -132,35 +120,11 @@ class MarkdownEditor {
|
|||||||
window.$events.emit('editor-markdown-change', content);
|
window.$events.emit('editor-markdown-change', content);
|
||||||
|
|
||||||
// Set body content
|
// Set body content
|
||||||
this.displayDoc.body.className = 'page-content';
|
this.display.innerHTML = html;
|
||||||
this.displayDoc.body.innerHTML = html;
|
|
||||||
|
|
||||||
// Copy styles from page head and set custom styles for editor
|
|
||||||
this.loadStylesIntoDisplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
loadStylesIntoDisplay() {
|
|
||||||
if (this.displayStylesLoaded) return;
|
|
||||||
this.displayDoc.documentElement.classList.add('markdown-editor-display');
|
|
||||||
// Set display to be dark mode if parent is
|
|
||||||
|
|
||||||
if (document.documentElement.classList.contains('dark-mode')) {
|
|
||||||
this.displayDoc.documentElement.style.backgroundColor = '#222';
|
|
||||||
this.displayDoc.documentElement.classList.add('dark-mode');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.displayDoc.head.innerHTML = '';
|
|
||||||
const styles = document.head.querySelectorAll('style,link[rel=stylesheet]');
|
|
||||||
for (let style of styles) {
|
|
||||||
const copy = style.cloneNode(true);
|
|
||||||
this.displayDoc.head.appendChild(copy);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.displayStylesLoaded = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMarkdownScroll(lineCount) {
|
onMarkdownScroll(lineCount) {
|
||||||
const elems = this.displayDoc.body.children;
|
const elems = this.display.children;
|
||||||
if (elems.length <= lineCount) return;
|
if (elems.length <= lineCount) return;
|
||||||
|
|
||||||
const topElem = (lineCount === -1) ? elems[elems.length-1] : elems[lineCount];
|
const topElem = (lineCount === -1) ? elems[elems.length-1] : elems[lineCount];
|
||||||
@@ -317,7 +281,7 @@ class MarkdownEditor {
|
|||||||
let cursor = cm.getCursor();
|
let cursor = cm.getCursor();
|
||||||
let lineContent = cm.getLine(cursor.line);
|
let lineContent = cm.getLine(cursor.line);
|
||||||
let lineLen = lineContent.length;
|
let lineLen = lineContent.length;
|
||||||
let newLineContent = lineContent;
|
let newLineContent;
|
||||||
|
|
||||||
if (lineContent.indexOf(start) === 0 && lineContent.slice(-end.length) === end) {
|
if (lineContent.indexOf(start) === 0 && lineContent.slice(-end.length) === end) {
|
||||||
newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
|
newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
|
||||||
@@ -333,9 +297,9 @@ class MarkdownEditor {
|
|||||||
let selection = cm.getSelection();
|
let selection = cm.getSelection();
|
||||||
if (selection === '') return wrapLine(start, end);
|
if (selection === '') return wrapLine(start, end);
|
||||||
|
|
||||||
let newSelection = selection;
|
let newSelection;
|
||||||
let frontDiff = 0;
|
let frontDiff = 0;
|
||||||
let endDiff = 0;
|
let endDiff;
|
||||||
|
|
||||||
if (selection.indexOf(start) === 0 && selection.slice(-end.length) === end) {
|
if (selection.indexOf(start) === 0 && selection.slice(-end.length) === end) {
|
||||||
newSelection = selection.slice(start.length, selection.length - end.length);
|
newSelection = selection.slice(start.length, selection.length - end.length);
|
||||||
@@ -445,10 +409,10 @@ class MarkdownEditor {
|
|||||||
|
|
||||||
DrawIO.show(url,() => {
|
DrawIO.show(url,() => {
|
||||||
return Promise.resolve('');
|
return Promise.resolve('');
|
||||||
}, (pngData) => {
|
}, (drawingData) => {
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
image: pngData,
|
image: drawingData,
|
||||||
uploaded_to: Number(this.pageId),
|
uploaded_to: Number(this.pageId),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -462,7 +426,7 @@ class MarkdownEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
insertDrawing(image, originalCursor) {
|
insertDrawing(image, originalCursor) {
|
||||||
const newText = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
|
const newText = DrawIO.buildDrawingContentHtml(image);
|
||||||
this.cm.focus();
|
this.cm.focus();
|
||||||
this.cm.replaceSelection(newText);
|
this.cm.replaceSelection(newText);
|
||||||
this.cm.setCursor(originalCursor.line, originalCursor.ch + newText.length);
|
this.cm.setCursor(originalCursor.line, originalCursor.ch + newText.length);
|
||||||
@@ -480,21 +444,22 @@ class MarkdownEditor {
|
|||||||
|
|
||||||
DrawIO.show(drawioUrl, () => {
|
DrawIO.show(drawioUrl, () => {
|
||||||
return DrawIO.load(drawingId);
|
return DrawIO.load(drawingId);
|
||||||
}, (pngData) => {
|
}, (drawingData) => {
|
||||||
|
|
||||||
let data = {
|
let data = {
|
||||||
image: pngData,
|
image: drawingData,
|
||||||
uploaded_to: Number(this.pageId),
|
uploaded_to: Number(this.pageId),
|
||||||
};
|
};
|
||||||
|
|
||||||
window.$http.post("/images/drawio", data).then(resp => {
|
window.$http.post("/images/drawio", data).then(resp => {
|
||||||
let newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`;
|
const image = resp.data;
|
||||||
let newContent = this.cm.getValue().split('\n').map(line => {
|
const newText = DrawIO.buildDrawingContentHtml(image);
|
||||||
if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
|
|
||||||
return newText;
|
const newContent = this.cm.getValue().split('\n').map(line => {
|
||||||
}
|
const isDrawing = line.includes(`drawio-diagram="${drawingId}"`);
|
||||||
return line;
|
return isDrawing ? newText : line;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
this.cm.setValue(newContent);
|
this.cm.setValue(newContent);
|
||||||
this.cm.setCursor(cursorPos);
|
this.cm.setCursor(cursorPos);
|
||||||
this.cm.focus();
|
this.cm.focus();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class PageEditor {
|
|||||||
this.draftDisplayIcon = this.$refs.draftDisplayIcon;
|
this.draftDisplayIcon = this.$refs.draftDisplayIcon;
|
||||||
this.changelogInput = this.$refs.changelogInput;
|
this.changelogInput = this.$refs.changelogInput;
|
||||||
this.changelogDisplay = this.$refs.changelogDisplay;
|
this.changelogDisplay = this.$refs.changelogDisplay;
|
||||||
this.changeEditorButtons = this.$manyRefs.changeEditor;
|
this.changeEditorButtons = this.$manyRefs.changeEditor || [];
|
||||||
this.switchDialogContainer = this.$refs.switchDialog;
|
this.switchDialogContainer = this.$refs.switchDialog;
|
||||||
|
|
||||||
// Translations
|
// Translations
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function drawEventExport(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawEventSave(message) {
|
function drawEventSave(message) {
|
||||||
drawPostMessage({action: 'export', format: 'xmlpng', xml: message.xml, spin: 'Updating drawing'});
|
drawPostMessage({action: 'export', format: 'xmlsvg', xml: message.xml, spin: 'Updating drawing'});
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawEventInit() {
|
function drawEventInit() {
|
||||||
@@ -96,7 +96,21 @@ async function upload(imageData, pageUploadedToId) {
|
|||||||
*/
|
*/
|
||||||
async function load(drawingId) {
|
async function load(drawingId) {
|
||||||
const resp = await window.$http.get(window.baseUrl(`/images/drawio/base64/${drawingId}`));
|
const resp = await window.$http.get(window.baseUrl(`/images/drawio/base64/${drawingId}`));
|
||||||
return `data:image/png;base64,${resp.data.content}`;
|
return resp.data.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {show, close, upload, load};
|
|
||||||
|
function buildDrawingContentHtml(drawing) {
|
||||||
|
const isSvg = drawing.url.split('.').pop().toLowerCase() === 'svg';
|
||||||
|
const image = `<img src="${drawing.url}">`;
|
||||||
|
const embed = `<embed src="${drawing.url}" type="image/svg+xml">`;
|
||||||
|
return `<div drawio-diagram="${drawing.id}">${isSvg ? embed : image}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDrawingContentNode(drawing) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = buildDrawingContentHtml(drawing);
|
||||||
|
return div.children[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {show, close, upload, load, buildDrawingContentHtml, buildDrawingContentNode};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import DrawIO from "../services/drawio";
|
import DrawIO from "../services/drawio";
|
||||||
|
import {build} from "./config";
|
||||||
|
|
||||||
let pageEditor = null;
|
let pageEditor = null;
|
||||||
let currentNode = null;
|
let currentNode = null;
|
||||||
@@ -15,15 +16,14 @@ function isDrawing(node) {
|
|||||||
function showDrawingManager(mceEditor, selectedNode = null) {
|
function showDrawingManager(mceEditor, selectedNode = null) {
|
||||||
pageEditor = mceEditor;
|
pageEditor = mceEditor;
|
||||||
currentNode = selectedNode;
|
currentNode = selectedNode;
|
||||||
|
|
||||||
// Show image manager
|
// Show image manager
|
||||||
window.ImageManager.show(function (image) {
|
window.ImageManager.show(function (image) {
|
||||||
if (selectedNode) {
|
if (selectedNode) {
|
||||||
let imgElem = selectedNode.querySelector('img');
|
pageEditor.dom.replace(buildDrawingNode(image), selectedNode);
|
||||||
pageEditor.dom.setAttrib(imgElem, 'src', image.url);
|
|
||||||
pageEditor.dom.setAttrib(selectedNode, 'drawio-diagram', image.id);
|
|
||||||
} else {
|
} else {
|
||||||
let imgHTML = `<div drawio-diagram="${image.id}" contenteditable="false"><img src="${image.url}"></div>`;
|
const drawingHtml = DrawIO.buildDrawingContentHtml(image);
|
||||||
pageEditor.insertContent(imgHTML);
|
pageEditor.insertContent(drawingHtml);
|
||||||
}
|
}
|
||||||
}, 'drawio');
|
}, 'drawio');
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,14 @@ function showDrawingEditor(mceEditor, selectedNode = null) {
|
|||||||
DrawIO.show(options.drawioUrl, drawingInit, updateContent);
|
DrawIO.show(options.drawioUrl, drawingInit, updateContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateContent(pngData) {
|
function buildDrawingNode(drawing) {
|
||||||
|
const drawingEl = DrawIO.buildDrawingContentNode(drawing);
|
||||||
|
drawingEl.setAttribute('contenteditable', 'false');
|
||||||
|
drawingEl.setAttribute('data-ephox-embed-iri', 'true');
|
||||||
|
return drawingEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateContent(drawingData) {
|
||||||
const id = "image-" + Math.random().toString(16).slice(2);
|
const id = "image-" + Math.random().toString(16).slice(2);
|
||||||
const loadingImage = window.baseUrl('/loading.gif');
|
const loadingImage = window.baseUrl('/loading.gif');
|
||||||
|
|
||||||
@@ -50,11 +57,9 @@ async function updateContent(pngData) {
|
|||||||
// Handle updating an existing image
|
// Handle updating an existing image
|
||||||
if (currentNode) {
|
if (currentNode) {
|
||||||
DrawIO.close();
|
DrawIO.close();
|
||||||
let imgElem = currentNode.querySelector('img');
|
|
||||||
try {
|
try {
|
||||||
const img = await DrawIO.upload(pngData, options.pageId);
|
const img = await DrawIO.upload(drawingData, options.pageId);
|
||||||
pageEditor.dom.setAttrib(imgElem, 'src', img.url);
|
pageEditor.dom.replace(buildDrawingNode(img), currentNode);
|
||||||
pageEditor.dom.setAttrib(currentNode, 'drawio-diagram', img.id);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleUploadError(err);
|
handleUploadError(err);
|
||||||
}
|
}
|
||||||
@@ -62,12 +67,11 @@ async function updateContent(pngData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
pageEditor.insertContent(`<div drawio-diagram contenteditable="false"><img src="${loadingImage}" id="${id}"></div>`);
|
pageEditor.insertContent(`<div drawio-diagram contenteditable="false"><img src="${loadingImage}" alt="Loading" id="${id}"></div>`);
|
||||||
DrawIO.close();
|
DrawIO.close();
|
||||||
try {
|
try {
|
||||||
const img = await DrawIO.upload(pngData, options.pageId);
|
const img = await DrawIO.upload(drawingData, options.pageId);
|
||||||
pageEditor.dom.setAttrib(id, 'src', img.url);
|
pageEditor.dom.replace(buildDrawingNode(img), pageEditor.dom.get(id).parentNode);
|
||||||
pageEditor.dom.get(id).parentNode.setAttribute('drawio-diagram', img.id);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pageEditor.dom.remove(id);
|
pageEditor.dom.remove(id);
|
||||||
handleUploadError(err);
|
handleUploadError(err);
|
||||||
@@ -86,7 +90,6 @@ function drawingInit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
|
||||||
* @param {WysiwygConfigOptions} providedOptions
|
* @param {WysiwygConfigOptions} providedOptions
|
||||||
* @return {function(Editor, string)}
|
* @return {function(Editor, string)}
|
||||||
*/
|
*/
|
||||||
@@ -130,14 +133,28 @@ export function getPlugin(providedOptions) {
|
|||||||
showDrawingEditor(editor, selectedNode);
|
showDrawingEditor(editor, selectedNode);
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.on('SetContent', function () {
|
editor.on('PreInit', () => {
|
||||||
const drawings = editor.$('body > div[drawio-diagram]');
|
editor.parser.addNodeFilter('div', function(nodes) {
|
||||||
if (!drawings.length) return;
|
for (const node of nodes) {
|
||||||
|
if (node.attr('drawio-diagram')) {
|
||||||
|
// Set content editable to be false to prevent direct editing of child content.
|
||||||
|
node.attr('contenteditable', 'false');
|
||||||
|
// Set this attribute to prevent drawing contents being parsed as media embeds
|
||||||
|
// to avoid contents being replaced with placeholder images.
|
||||||
|
// TinyMCE embed plugin sources looks for this attribute in its logic.
|
||||||
|
node.attr('data-ephox-embed-iri', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
editor.undoManager.transact(function () {
|
editor.serializer.addNodeFilter('div', function(nodes) {
|
||||||
drawings.each((index, elem) => {
|
for (const node of nodes) {
|
||||||
elem.setAttribute('contenteditable', 'false');
|
// Clean up content attributes
|
||||||
});
|
if (node.attr('drawio-diagram')) {
|
||||||
|
node.attr('contenteditable', null);
|
||||||
|
node.attr('data-ephox-embed-iri', null);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -114,26 +114,20 @@
|
|||||||
|
|
||||||
.markdown-display {
|
.markdown-display {
|
||||||
margin-inline-start: -1px;
|
margin-inline-start: -1px;
|
||||||
}
|
display: block;
|
||||||
|
|
||||||
.markdown-editor-display {
|
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
body {
|
padding: $-m;
|
||||||
display: block;
|
overflow-y: scroll;
|
||||||
background-color: #fff;
|
|
||||||
padding-inline-start: 16px;
|
|
||||||
padding-inline-end: 16px;
|
|
||||||
}
|
|
||||||
[drawio-diagram]:hover {
|
[drawio-diagram]:hover {
|
||||||
outline: 2px solid var(--color-primary);
|
outline: 2px solid var(--color-primary);
|
||||||
}
|
}
|
||||||
|
[drawio-diagram] embed {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html.markdown-editor-display.dark-mode {
|
.dark-mode .markdown-display {
|
||||||
background-color: #222;
|
background-color: #222;
|
||||||
body {
|
|
||||||
background-color: #222;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar {
|
.editor-toolbar {
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ body.page-content.mce-content-body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent interaction with embed contents
|
||||||
|
.page-content.mce-content-body embed {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
// Details/summary editor usability
|
// Details/summary editor usability
|
||||||
.page-content.mce-content-body details summary {
|
.page-content.mce-content-body details summary {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
<div markdown-input class="flex flex-fill">
|
<div markdown-input class="flex flex-fill">
|
||||||
<textarea id="markdown-editor-input"
|
<textarea id="markdown-editor-input"
|
||||||
|
refs="markdown-editor@input"
|
||||||
@if($errors->has('markdown')) class="text-neg" @endif
|
@if($errors->has('markdown')) class="text-neg" @endif
|
||||||
name="markdown"
|
name="markdown"
|
||||||
rows="5">@if(isset($model) || old('markdown')){{ old('markdown') ?? ($model->markdown === '' ? $model->html : $model->markdown) }}@endif</textarea>
|
rows="5">@if(isset($model) || old('markdown')){{ old('markdown') ?? ($model->markdown === '' ? $model->html : $model->markdown) }}@endif</textarea>
|
||||||
@@ -34,7 +35,10 @@
|
|||||||
<div class="editor-toolbar">
|
<div class="editor-toolbar">
|
||||||
<div class="editor-toolbar-label">{{ trans('entities.pages_md_preview') }}</div>
|
<div class="editor-toolbar-label">{{ trans('entities.pages_md_preview') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<iframe src="about:blank" class="markdown-display" sandbox="allow-same-origin"></iframe>
|
<div class="markdown-display">
|
||||||
|
<div refs="markdown-editor@display"
|
||||||
|
class="page-content"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -258,6 +258,24 @@ class ExportTest extends TestCase
|
|||||||
unlink($testFilePath);
|
unlink($testFilePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_page_export_contained_html_embed_elements_are_converted_to_images_with_srcs_inlined()
|
||||||
|
{
|
||||||
|
$page = Page::query()->first();
|
||||||
|
$page->html = '<embed src="http://localhost/uploads/images/gallery/svg_test.svg"/>';
|
||||||
|
$page->save();
|
||||||
|
|
||||||
|
$storageDisk = Storage::disk('local');
|
||||||
|
$storageDisk->makeDirectory('uploads/images/gallery');
|
||||||
|
$storageDisk->put('uploads/images/gallery/svg_test.svg', '<svg>good</svg>');
|
||||||
|
|
||||||
|
$resp = $this->asEditor()->get($page->getUrl('/export/html'));
|
||||||
|
|
||||||
|
$storageDisk->delete('uploads/images/gallery/svg_test.svg');
|
||||||
|
|
||||||
|
$resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test.svg', false);
|
||||||
|
$resp->assertSee('<img src="data:image/svg+xml;base64,PHN2Zz5nb29kPC9zdmc+">', false);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_exports_removes_scripts_from_custom_head()
|
public function test_exports_removes_scripts_from_custom_head()
|
||||||
{
|
{
|
||||||
$entities = [
|
$entities = [
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class DrawioTest extends TestCase
|
|||||||
{
|
{
|
||||||
use UsesImages;
|
use UsesImages;
|
||||||
|
|
||||||
public function test_get_image_as_base64()
|
public function test_get_image_as_base64_with_png_content()
|
||||||
{
|
{
|
||||||
$page = Page::first();
|
$page = Page::first();
|
||||||
$this->asAdmin();
|
$this->asAdmin();
|
||||||
@@ -23,11 +23,27 @@ class DrawioTest extends TestCase
|
|||||||
|
|
||||||
$imageGet = $this->getJson("/images/drawio/base64/{$image->id}");
|
$imageGet = $this->getJson("/images/drawio/base64/{$image->id}");
|
||||||
$imageGet->assertJson([
|
$imageGet->assertJson([
|
||||||
'content' => 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=',
|
'content' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_drawing_base64_upload()
|
public function test_get_image_as_base64_with_svg_content()
|
||||||
|
{
|
||||||
|
$page = Page::first();
|
||||||
|
$this->asAdmin();
|
||||||
|
|
||||||
|
$this->uploadImage('my-drawing.svg', $page->id, 'image/svg+xml', 'diagram.svg');
|
||||||
|
$image = Image::first();
|
||||||
|
$image->type = 'drawio';
|
||||||
|
$image->save();
|
||||||
|
|
||||||
|
$imageGet = $this->getJson("/images/drawio/base64/{$image->id}");
|
||||||
|
$imageGet->assertJson([
|
||||||
|
'content' => 'data:image/svg+xml;base64,' . base64_encode(file_get_contents($this->getTestImageFilePath('diagram.svg'))),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_drawing_base64_upload_with_png()
|
||||||
{
|
{
|
||||||
$page = Page::first();
|
$page = Page::first();
|
||||||
$editor = $this->getEditor();
|
$editor = $this->getEditor();
|
||||||
@@ -35,7 +51,7 @@ class DrawioTest extends TestCase
|
|||||||
|
|
||||||
$upload = $this->postJson('images/drawio', [
|
$upload = $this->postJson('images/drawio', [
|
||||||
'uploaded_to' => $page->id,
|
'uploaded_to' => $page->id,
|
||||||
'image' => 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=',
|
'image' => 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$upload->assertStatus(200);
|
$upload->assertStatus(200);
|
||||||
@@ -54,6 +70,34 @@ class DrawioTest extends TestCase
|
|||||||
$this->assertTrue($testImageData === $uploadedImageData, 'Uploaded image file data does not match our test image as expected');
|
$this->assertTrue($testImageData === $uploadedImageData, 'Uploaded image file data does not match our test image as expected');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_drawing_base64_upload_with_svg()
|
||||||
|
{
|
||||||
|
$page = Page::first();
|
||||||
|
$editor = $this->getEditor();
|
||||||
|
$this->actingAs($editor);
|
||||||
|
|
||||||
|
$upload = $this->postJson('images/drawio', [
|
||||||
|
'uploaded_to' => $page->id,
|
||||||
|
'image' => 'data:image/svg+xml;base64,' . base64_encode(file_get_contents($this->getTestImageFilePath('diagram.svg'))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$upload->assertStatus(200);
|
||||||
|
$upload->assertJson([
|
||||||
|
'type' => 'drawio',
|
||||||
|
'uploaded_to' => $page->id,
|
||||||
|
'created_by' => $editor->id,
|
||||||
|
'updated_by' => $editor->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$image = Image::where('type', '=', 'drawio')->first();
|
||||||
|
$this->assertStringEndsWith('.svg', $image->path);
|
||||||
|
$this->assertTrue(file_exists(public_path($image->path)), 'Uploaded image not found at path: ' . public_path($image->path));
|
||||||
|
|
||||||
|
$testImageData = file_get_contents($this->getTestImageFilePath('diagram.svg'));
|
||||||
|
$uploadedImageData = file_get_contents(public_path($image->path));
|
||||||
|
$this->assertTrue($testImageData === $uploadedImageData, 'Uploaded image file data does not match our test image as expected');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_drawio_url_can_be_configured()
|
public function test_drawio_url_can_be_configured()
|
||||||
{
|
{
|
||||||
config()->set('services.drawio', 'http://cats.com?dog=tree');
|
config()->set('services.drawio', 'http://cats.com?dog=tree');
|
||||||
|
|||||||
@@ -74,6 +74,23 @@ class ImageTest extends TestCase
|
|||||||
$this->assertStringNotContainsString('thumbs-', $imgDetails['response']->thumbs->display);
|
$this->assertStringNotContainsString('thumbs-', $imgDetails['response']->thumbs->display);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_svg_upload()
|
||||||
|
{
|
||||||
|
/** @var Page $page */
|
||||||
|
$page = Page::query()->first();
|
||||||
|
$admin = $this->getAdmin();
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
$imgDetails = $this->uploadGalleryImage($page, 'diagram.svg', 'image/svg+xml');
|
||||||
|
$this->assertFileExists(public_path($imgDetails['path']));
|
||||||
|
$this->assertTrue(
|
||||||
|
$imgDetails['response']->url === $imgDetails['response']->thumbs->gallery
|
||||||
|
&& $imgDetails['response']->url === $imgDetails['response']->thumbs->display,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->deleteImage($imgDetails['path']);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_image_edit()
|
public function test_image_edit()
|
||||||
{
|
{
|
||||||
$editor = $this->getEditor();
|
$editor = $this->getEditor();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Tests\Uploads;
|
namespace Tests\Uploads;
|
||||||
|
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Entities\Models\Page;
|
||||||
|
use BookStack\Uploads\Image;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use stdClass;
|
use stdClass;
|
||||||
|
|
||||||
@@ -39,9 +40,9 @@ trait UsesImages
|
|||||||
/**
|
/**
|
||||||
* Get a test image that can be uploaded.
|
* Get a test image that can be uploaded.
|
||||||
*/
|
*/
|
||||||
protected function getTestImage(string $fileName, ?string $testDataFileName = null): UploadedFile
|
protected function getTestImage(string $fileName, ?string $testDataFileName = null, $mimeType = 'image/png'): UploadedFile
|
||||||
{
|
{
|
||||||
return new UploadedFile($this->getTestImageFilePath($testDataFileName), $fileName, 'image/png', null, true);
|
return new UploadedFile($this->getTestImageFilePath($testDataFileName), $fileName, $mimeType, null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,7 +74,7 @@ trait UsesImages
|
|||||||
*/
|
*/
|
||||||
protected function uploadImage($name, $uploadedTo = 0, $contentType = 'image/png', ?string $testDataFileName = null)
|
protected function uploadImage($name, $uploadedTo = 0, $contentType = 'image/png', ?string $testDataFileName = null)
|
||||||
{
|
{
|
||||||
$file = $this->getTestImage($name, $testDataFileName);
|
$file = $this->getTestImage($name, $testDataFileName, $contentType);
|
||||||
|
|
||||||
return $this->withHeader('Content-Type', $contentType)
|
return $this->withHeader('Content-Type', $contentType)
|
||||||
->call('POST', '/images/gallery', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
|
->call('POST', '/images/gallery', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
|
||||||
@@ -84,11 +85,9 @@ trait UsesImages
|
|||||||
* Returns the image name.
|
* Returns the image name.
|
||||||
* Can provide a page to relate the image to.
|
* Can provide a page to relate the image to.
|
||||||
*
|
*
|
||||||
* @param Page|null $page
|
|
||||||
*
|
|
||||||
* @return array{name: string, path: string, page: Page, response: stdClass}
|
* @return array{name: string, path: string, page: Page, response: stdClass}
|
||||||
*/
|
*/
|
||||||
protected function uploadGalleryImage(Page $page = null, ?string $testDataFileName = null)
|
protected function uploadGalleryImage(Page $page = null, string $testDataFileName = null, string $contentType = 'image/png')
|
||||||
{
|
{
|
||||||
if ($page === null) {
|
if ($page === null) {
|
||||||
$page = Page::query()->first();
|
$page = Page::query()->first();
|
||||||
@@ -98,7 +97,7 @@ trait UsesImages
|
|||||||
$relPath = $this->getTestImagePath('gallery', $imageName);
|
$relPath = $this->getTestImagePath('gallery', $imageName);
|
||||||
$this->deleteImage($relPath);
|
$this->deleteImage($relPath);
|
||||||
|
|
||||||
$upload = $this->uploadImage($imageName, $page->id, 'image/png', $testDataFileName);
|
$upload = $this->uploadImage($imageName, $page->id, $contentType, $testDataFileName);
|
||||||
$upload->assertStatus(200);
|
$upload->assertStatus(200);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|||||||
3
tests/test-data/diagram.svg
Normal file
3
tests/test-data/diagram.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" style="background-color: rgb(255, 255, 255);" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="121px" height="141px" viewBox="-0.5 -0.5 121 141"><defs/><g><ellipse cx="25" cy="87.5" rx="7.5" ry="7.5" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/><path d="M 25 95 L 25 120 M 25 100 L 10 100 M 25 100 L 40 100 M 25 120 L 10 140 M 25 120 L 40 140" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/><path d="M 0 0 L 120 0 L 120 50 L 80 50 L 60 80 L 60 50 L 0 50 Z" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject style="overflow: visible; text-align: left;" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 25px; margin-left: 1px;"><div style="box-sizing: border-box; font-size: 0px; text-align: center;" data-drawio-colors="color: rgb(0, 0, 0); "><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">Hello!</div></div></div></foreignObject><text x="60" y="29" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">Hello!</text></switch></g></g><switch><g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/><a transform="translate(0,-5)" xlink:href="https://www.diagrams.net/doc/faq/svg-export-text-problems" target="_blank"><text text-anchor="middle" font-size="10px" x="50%" y="100%">Text is not SVG - cannot display</text></a></switch></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
Reference in New Issue
Block a user