You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Desktop: Fixes #10733: Fix not-yet-created images lost while editing with the Rich Text Editor (#10734)
This commit is contained in:
		| @@ -2,13 +2,16 @@ import MdToHtml from '@joplin/renderer/MdToHtml'; | ||||
| const { filename } = require('@joplin/lib/path-utils'); | ||||
| import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import { RenderOptions } from '@joplin/renderer/types'; | ||||
| import { isResourceUrl, resourceUrlToId } from '@joplin/lib/models/utils/resourceUtils'; | ||||
| const { themeStyle } = require('@joplin/lib/theme'); | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| function newTestMdToHtml(options: any = null) { | ||||
| 	options = { | ||||
| 		ResourceModel: { | ||||
| 			isResourceUrl: () => false, | ||||
| 			isResourceUrl: isResourceUrl, | ||||
| 			urlToId: resourceUrlToId, | ||||
| 		}, | ||||
| 		fsDriver: shim.fsDriver(), | ||||
| 		...options, | ||||
| @@ -39,7 +42,7 @@ describe('MdToHtml', () => { | ||||
| 			// if (mdFilename !== 'sanitize_9.md') continue; | ||||
|  | ||||
| 			// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 			const mdToHtmlOptions: any = { | ||||
| 			const mdToHtmlOptions: RenderOptions = { | ||||
| 				bodyOnly: true, | ||||
| 			}; | ||||
|  | ||||
| @@ -51,6 +54,8 @@ describe('MdToHtml', () => { | ||||
| 				}; | ||||
| 			} else if (mdFilename.startsWith('sourcemap_')) { | ||||
| 				mdToHtmlOptions.mapsToLine = true; | ||||
| 			} else if (mdFilename.startsWith('resource_')) { | ||||
| 				mdToHtmlOptions.resources = {}; | ||||
| 			} | ||||
|  | ||||
| 			const markdown = await shim.fsDriver().readFile(mdFilePath); | ||||
|   | ||||
							
								
								
									
										48
									
								
								packages/app-cli/tests/html_to_md/resource_placeholder.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								packages/app-cli/tests/html_to_md/resource_placeholder.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| <p>Markdown images:</p> | ||||
| <ul> | ||||
| 	<li> | ||||
| 		With ALT and title: | ||||
| 		<div | ||||
| 			class="not-loaded-resource not-loaded-image-resource resource-status-test" | ||||
| 			data-original-alt="test" | ||||
| 			data-original-title="testing" | ||||
| 			data-resource-id="0415d61cc33e47afa6dde45948c3177f" | ||||
| 		> | ||||
| 			<img src="data:image/svg+xml;utf8,some-icon-here"/> | ||||
| 		</div> | ||||
| 	</li> | ||||
| 	<li> | ||||
| 		With neither ALT nor title: | ||||
| 		<div | ||||
| 			class="not-loaded-resource not-loaded-image-resource resource-status-error" | ||||
| 			data-original-alt="" | ||||
| 			data-original-title="" | ||||
| 			data-resource-id="0a25d61cc33e57afa6dde45948c3177f" | ||||
| 		> | ||||
| 			<img src="data:image/svg+xml;utf8,some-icon-here"/> | ||||
| 		</div> | ||||
| 	</li> | ||||
| </ul> | ||||
|  | ||||
| <p>HTML images:</p> | ||||
| <ul> | ||||
| 	<li> | ||||
| 		<div | ||||
| 			class="not-loaded-resource not-loaded-image-resource resource-status-error" | ||||
| 			data-original-before=" width="230"" | ||||
| 			data-original-after=" style="border: 32px inset red;"/" | ||||
| 			data-resource-id="0415d61cc33e47afa6dde45948c3177f" | ||||
| 		> | ||||
| 			<img src="data:image/svg+xml;utf8,some-icon-here"/> | ||||
| 		</div> | ||||
| 	</li> | ||||
| 	<li> | ||||
| 		<div | ||||
| 			class="not-loaded-resource not-loaded-image-resource resource-status-error" | ||||
| 			data-original-after="/" | ||||
| 			data-resource-id="0415d61cc33e47afa6dde45948c3177f" | ||||
| 		> | ||||
| 			<img src="data:image/svg+xml;utf8,some-icon-here"/> | ||||
| 		</div> | ||||
| 	</li> | ||||
| </ul> | ||||
| @@ -0,0 +1,9 @@ | ||||
| Markdown images: | ||||
|  | ||||
| - With ALT and title: | ||||
| - With neither ALT nor title: | ||||
|  | ||||
| HTML images: | ||||
|  | ||||
| - <img width="230" src=":/0415d61cc33e47afa6dde45948c3177f" style="border: 32px inset red;"/> | ||||
| - <img src=":/0415d61cc33e47afa6dde45948c3177f" /> | ||||
| @@ -0,0 +1,15 @@ | ||||
| <div class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22345" data-original-alt data-original-title="test" contenteditable="false"><img src="data:image/svg+xml;utf8, | ||||
| 		<svg width="1700" height="1536" xmlns="http://www.w3.org/2000/svg"> | ||||
| 		    <path d="M1280 1344c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm256 0c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm128-224v320c0 53-43 96-96 96H96c-53 0-96-43-96-96v-320c0-53 43-96 96-96h465l135 136c37 36 85 56 136 56s99-20 136-56l136-136h464c53 0 96 43 96 96zm-325-569c10 24 5 52-14 70l-448 448c-12 13-29 19-45 19s-33-6-45-19L339 621c-19-18-24-46-14-70 10-23 33-39 59-39h256V64c0-35 29-64 64-64h256c35 0 64 29 64 64v448h256c26 0 49 16 59 39z"/> | ||||
| 		</svg> | ||||
| 	"/></div> | ||||
| <div class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22346" data-original-alt="test" data-original-title contenteditable="false"><img src="data:image/svg+xml;utf8, | ||||
| 		<svg width="1700" height="1536" xmlns="http://www.w3.org/2000/svg"> | ||||
| 		    <path d="M1280 1344c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm256 0c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm128-224v320c0 53-43 96-96 96H96c-53 0-96-43-96-96v-320c0-53 43-96 96-96h465l135 136c37 36 85 56 136 56s99-20 136-56l136-136h464c53 0 96 43 96 96zm-325-569c10 24 5 52-14 70l-448 448c-12 13-29 19-45 19s-33-6-45-19L339 621c-19-18-24-46-14-70 10-23 33-39 59-39h256V64c0-35 29-64 64-64h256c35 0 64 29 64 64v448h256c26 0 49 16 59 39z"/> | ||||
| 		</svg> | ||||
| 	"/></div> | ||||
| <div class="not-loaded-resource not-loaded-image-resource resource-status-notDownloaded" data-resource-id="a1test2a1test2a1test2a1test22347" data-original-before=" " data-original-after=" class="jop-noMdConv"/" contenteditable="false"><img src="data:image/svg+xml;utf8, | ||||
| 		<svg width="1700" height="1536" xmlns="http://www.w3.org/2000/svg"> | ||||
| 		    <path d="M1280 1344c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm256 0c0-35-29-64-64-64s-64 29-64 64 29 64 64 64 64-29 64-64zm128-224v320c0 53-43 96-96 96H96c-53 0-96-43-96-96v-320c0-53 43-96 96-96h465l135 136c37 36 85 56 136 56s99-20 136-56l136-136h464c53 0 96 43 96 96zm-325-569c10 24 5 52-14 70l-448 448c-12 13-29 19-45 19s-33-6-45-19L339 621c-19-18-24-46-14-70 10-23 33-39 59-39h256V64c0-35 29-64 64-64h256c35 0 64 29 64 64v448h256c26 0 49 16 59 39z"/> | ||||
| 		</svg> | ||||
| 	"/></div> | ||||
| @@ -0,0 +1,3 @@ | ||||
|  | ||||
|  | ||||
| <img src=":/a1test2a1test2a1test2a1test22347"/> | ||||
| @@ -28,6 +28,7 @@ export default class HtmlToMd { | ||||
| 			bulletListMarker: '-', | ||||
| 			emDelimiter: '*', | ||||
| 			strongDelimiter: '**', | ||||
| 			allowResourcePlaceholders: true, | ||||
|  | ||||
| 			// If soft-breaks are enabled, lines need to end with two or more spaces for | ||||
| 			// trailing <br/>s to render. See | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import { attributesHtml } from '../../htmlUtils'; | ||||
| import * as utils from '../../utils'; | ||||
|  | ||||
| function renderImageHtml(before: string, src: string, after: string, ruleOptions: RuleOptions) { | ||||
| 	const r = utils.imageReplacement(ruleOptions.ResourceModel, src, ruleOptions.resources, ruleOptions.resourceBaseUrl, ruleOptions.itemIdToUrl); | ||||
| 	const r = utils.imageReplacement(ruleOptions.ResourceModel, { src, before, after }, ruleOptions.resources, ruleOptions.resourceBaseUrl, ruleOptions.itemIdToUrl); | ||||
| 	if (typeof r === 'string') return r; | ||||
| 	if (r) return `<img ${before} ${attributesHtml(r)} ${after}/>`; | ||||
| 	return `[Image: ${src}]`; | ||||
|   | ||||
| @@ -17,7 +17,8 @@ function plugin(markdownIt: any, ruleOptions: RuleOptions) { | ||||
|  | ||||
| 		if (!Resource.isResourceUrl(src) || ruleOptions.plainResourceRendering) return defaultRender(tokens, idx, options, env, self); | ||||
|  | ||||
| 		const r = utils.imageReplacement(ruleOptions.ResourceModel, src, ruleOptions.resources, ruleOptions.resourceBaseUrl, ruleOptions.itemIdToUrl); | ||||
| 		const alt = token.content; | ||||
| 		const r = utils.imageReplacement(ruleOptions.ResourceModel, { src, alt, title }, ruleOptions.resources, ruleOptions.resourceBaseUrl, ruleOptions.itemIdToUrl); | ||||
| 		if (typeof r === 'string') return r; | ||||
| 		if (r) { | ||||
| 			const id = r['data-resource-id']; | ||||
| @@ -35,7 +36,7 @@ function plugin(markdownIt: any, ruleOptions: RuleOptions) { | ||||
| 				destroyEditPopupSyntax: ruleOptions.destroyEditPopupSyntax, | ||||
| 			}, null); | ||||
|  | ||||
| 			return `<img data-from-md ${attributesHtml({ ...r, title: title, alt: token.content })} ${js}/>`; | ||||
| 			return `<img data-from-md ${attributesHtml({ ...r, title: title, alt })} ${js}/>`; | ||||
| 		} | ||||
| 		return defaultRender(tokens, idx, options, env, self); | ||||
| 	}; | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import { attributesHtml } from './htmlUtils'; | ||||
| import { ItemIdToUrlHandler, OptionsResourceModel } from './types'; | ||||
|  | ||||
| const Entities = require('html-entities').AllHtmlEntities; | ||||
| @@ -123,10 +124,17 @@ export const resourceStatus = function(ResourceModel: OptionsResourceModel, reso | ||||
| 	return status; | ||||
| }; | ||||
|  | ||||
| type ImageMarkupData = { | ||||
| 	src: string; | ||||
| 	alt: string; | ||||
| 	title: string; | ||||
| }|{ src: string; before: string; after: string }; | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| export const imageReplacement = function(ResourceModel: OptionsResourceModel, src: string, resources: any, resourceBaseUrl: string, itemIdToUrl: ItemIdToUrlHandler = null) { | ||||
| export const imageReplacement = function(ResourceModel: OptionsResourceModel, markup: ImageMarkupData, resources: any, resourceBaseUrl: string, itemIdToUrl: ItemIdToUrlHandler = null) { | ||||
| 	if (!ResourceModel || !resources) return null; | ||||
|  | ||||
| 	const src = markup.src; | ||||
| 	if (!ResourceModel.isResourceUrl(src)) return null; | ||||
|  | ||||
| 	const resourceId = ResourceModel.urlToId(src); | ||||
| @@ -136,7 +144,28 @@ export const imageReplacement = function(ResourceModel: OptionsResourceModel, sr | ||||
|  | ||||
| 	if (status !== 'ready') { | ||||
| 		const icon = resourceStatusImage(status); | ||||
| 		return `<div class="not-loaded-resource resource-status-${status}" data-resource-id="${resourceId}">` + `<img src="data:image/svg+xml;utf8,${htmlentities(icon)}"/>` + '</div>'; | ||||
|  | ||||
| 		// Preserve information necessary to restore the original markup when converting | ||||
| 		// from HTML to markdown. | ||||
| 		const attrs: Record<string, string> = { | ||||
| 			class: `not-loaded-resource not-loaded-image-resource resource-status-${status}`, | ||||
| 			['data-resource-id']: resourceId, | ||||
| 		}; | ||||
| 		if ('alt' in markup) { | ||||
| 			attrs['data-original-alt'] = markup.alt; | ||||
| 			attrs['data-original-title'] = markup.title; | ||||
| 		} else { | ||||
| 			attrs['data-original-before'] = markup.before; | ||||
| 			attrs['data-original-after'] = markup.after; | ||||
| 		} | ||||
|  | ||||
| 		// contenteditable="false": Improves support for the Rich Text Editor -- without this, | ||||
| 		// users can add content within the <div>, which breaks the html-to-md conversion. | ||||
| 		return ( | ||||
| 			`<div ${attributesHtml(attrs)} contenteditable="false">` | ||||
| 				+ `<img src="data:image/svg+xml;utf8,${htmlentities(icon)}"/>` | ||||
| 			+ '</div>' | ||||
| 		); | ||||
| 	} | ||||
| 	const mime = resource.mime ? resource.mime.toLowerCase() : ''; | ||||
| 	if (ResourceModel.isSupportedImageMimeType(mime)) { | ||||
|   | ||||
| @@ -140,6 +140,43 @@ rules.foregroundColor = { | ||||
|   }, | ||||
| } | ||||
|  | ||||
| // Converts placeholders for not-loaded resources. | ||||
| rules.resourcePlaceholder = { | ||||
|   filter: function (node, options) { | ||||
|     if (!options.allowResourcePlaceholders) return false; | ||||
|     if (!node.classList || !node.classList.contains('not-loaded-resource')) return false; | ||||
|     const isImage = node.classList.contains('not-loaded-image-resource'); | ||||
|     if (!isImage) return false; | ||||
|  | ||||
|     const resourceId = node.getAttribute('data-resource-id'); | ||||
|     return resourceId && resourceId.match(/^[a-z0-9]{32}$/); | ||||
|   }, | ||||
|  | ||||
|   replacement: function (_content, node) { | ||||
|     const htmlBefore = node.getAttribute('data-original-before') || ''; | ||||
|     const htmlAfter = node.getAttribute('data-original-after') || ''; | ||||
|     const isHtml = htmlBefore || htmlAfter; | ||||
|     const resourceId = node.getAttribute('data-resource-id'); | ||||
|     if (isHtml) { | ||||
|       const attrs = [ | ||||
|         htmlBefore.trim(), | ||||
|         `src=":/${resourceId}"`, | ||||
|         htmlAfter.trim(), | ||||
|       ].filter(a => !!a); | ||||
|  | ||||
|       return `<img ${attrs.join(' ')}>`; | ||||
|     } else { | ||||
|       const originalAltText = node.getAttribute('data-original-alt') || ''; | ||||
|       const title = node.getAttribute('data-original-title'); | ||||
|       return imageMarkdownFromAttributes({ | ||||
|         alt: originalAltText, | ||||
|         title, | ||||
|         src: `:/${resourceId}`, | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| // ============================== | ||||
| // END Joplin format support | ||||
| // ============================== | ||||
| @@ -510,6 +547,14 @@ rules.code = { | ||||
|   } | ||||
| } | ||||
|  | ||||
| function imageMarkdownFromAttributes(attributes) { | ||||
|   var alt = attributes.alt || '' | ||||
|   var src = filterLinkHref(attributes.src || '') | ||||
|   var title = attributes.title || '' | ||||
|   var titlePart = title ? ' "' + filterImageTitle(title) + '"' : '' | ||||
|   return src ? '![' + alt.replace(/([[\]])/g, '\\$1') + ']' + '(' + src + titlePart + ')' : '' | ||||
| } | ||||
|  | ||||
| function imageMarkdownFromNode(node, options = null) { | ||||
|   options = Object.assign({}, { | ||||
|     preserveImageTagsWithSize: false, | ||||
| @@ -519,11 +564,11 @@ function imageMarkdownFromNode(node, options = null) { | ||||
|     return node.outerHTML; | ||||
|   } | ||||
|  | ||||
|   var alt = node.alt || '' | ||||
|   var src = filterLinkHref(node.getAttribute('src') || '') | ||||
|   var title = node.title || '' | ||||
|   var titlePart = title ? ' "' + filterImageTitle(title) + '"' : '' | ||||
|   return src ? '![' + alt.replace(/([[\]])/g, '\\$1') + ']' + '(' + src + titlePart + ')' : '' | ||||
|   return imageMarkdownFromAttributes({ | ||||
|     alt: node.alt, | ||||
|     src: node.getAttribute('src'), | ||||
|     title: node.title, | ||||
|   }); | ||||
| } | ||||
|  | ||||
| function imageUrlFromSource(node) { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user