Skip to content

Commit

Permalink
Automatically switch to preview
Browse files Browse the repository at this point in the history
- Automate the update of the preview
- Display a modal during the copy so the focus cannot be changed, and to
  provide user feedback that the copy is being built since it can take a few
  seconds.
- Display a progressbar to show image loading
- Added LICENSE
  • Loading branch information
mvdkwast committed Oct 31, 2022
1 parent cbfb2b0 commit b4dab1a
Show file tree
Hide file tree
Showing 4 changed files with 224 additions and 71 deletions.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2022 Martijn van der Kwast

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
32 changes: 18 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,36 @@ HTML aware application like gmail.
This plugin exposes the `Copy as HTML: Copy the current document to clipboard` command, which can be bound to a keyboard
shortcut.

> **WARNING**: From edit mode enter preview mode and go back to edit mode to make sure the view is up-to-date.
## Support

Currently working with :
- [x] images
- [x] plantuml
- [x] Excalidraw
- [x] diagrams
- [x] obsidian-tasks
- [x] obsidian-dataview

- ✅ images
- ✅ plantuml
- ✅ diagrams
- ✅ obsidian-tasks
- 👷 obsidian-dataview - content may be missing. Work-around: copy twice or switch to preview mode and back.
- 👷 Excalidraw - seems to cause the content to be duplicated in gmail, but not in
other editors like [RichTextEditor](https://richtexteditor.com/demos/basic_editor.aspx).

## Implementation

The plugin converts image references to data urls so images from the vault are included.
The plugin converts image references to data urls, so no references to the vault are included in the HTML.

## Known issues

- The plugin uses the HTML from the preview mode, which may not be up-to-date, hence needing to switch back and forth.
- Don't change focus until a notification saying "document copied to clipboard" appears.
- Only works in edit mode.
- Post-processors like dataview may not have post-processed the preview documents, which may cause missing data
- The Excalidraw plugin output seems to confuse gmail, although the output looks ok.
- Special fields (front-matter, double-colon attributes, ...) are not removed.
- data-urls can use a lot of memory for big/many pictures

## TODO / wish-list

- Automate the update of the preview
- Display a modal during the copy so the focus cannot be changed, and to provide user feedback that the copy is being
built since it can take a few seconds.
- Adjust image resolution / quality
- Wait for dataview & co to be ready
- Should be usable in preview mode also, using `Workspace.activeFile`

## Development

Please see the [Obsidian sample plugin](https://github.com/obsidianmd/obsidian-sample-plugin).
238 changes: 183 additions & 55 deletions main.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,170 @@
import {Editor, MarkdownView, Notice, Plugin} from 'obsidian';
import {App, Editor, MarkdownView, Modal, Notice, Plugin} from 'obsidian';

/*
* Generic lib functions
*/

/**
* Like Promise.all(), but with a callback to indicate progress. Graciously lifted from
* https://stackoverflow.com/a/42342373/1341132
*/
function allWithProgress(promises: Promise<any>[], callback: (percentCompleted: number) => void) {
let count = 0;
callback(0);
for (const promise of promises) {
promise.then(() => {
count++;
callback((count * 100) / promises.length);
});
}
return Promise.all(promises);
}

/**
* Do nothing for a while
*/
async function delay(milliseconds: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}

/**
* Modify document preview HTML to embed pictures and do some cleanup for pasting.
*/
class HTMLConverter {
private modal: CopyingToHtmlModal;

async function imageToUri(url: string): Promise<string> {
if (url.startsWith('data:')) {
return url;
constructor(private view: MarkdownView, private app: App) {
}

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
public async documentToHTML(): Promise<HTMLElement> {
this.modal = new CopyingToHtmlModal(this.app);
this.modal.open();

try {
// @ts-ignore
this.app.commands.executeCommandById('markdown:toggle-preview');
const topNode = await this.waitForPreviewToLoad();
return await this.transformHTML(topNode!);
} finally {
this.modal.close();

// Return to edit view
// @ts-ignore
this.app.commands.executeCommandById('markdown:toggle-preview');
}
}

const base_image = new Image();
base_image.setAttribute('crossOrigin', 'anonymous');
/**
* When we switch to preview mode it takes some time before the preview is rendered. Wait until it contains
* a known child node to ensure it is loaded
* FIXME: this is not robust, and we should wait for some event that indicates that preview has finished loading
* @private
* @returns a ready preview element
*/
private async waitForPreviewToLoad(): Promise<HTMLElement> {
// @ts-ignore
const topNode: HTMLElement = this.view.contentEl.querySelector('.markdown-reading-view .markdown-preview-section');

// wait maximum 10s (at 20 tests per second) for the preview to be loaded
const MAX_TRIALS = 20 * 10;
for (let trial = 0; trial < MAX_TRIALS; ++trial) {
const pusher = topNode.querySelector('.markdown-preview-pusher');
if (pusher !== null) {
// work-around - see fixme above
await delay(250);
// found it -> the preview is loaded
return topNode;
}
await delay(50);
}

console.log(`converting image: ${url.substring(0, 40)}`);
throw Error('Preview could not be loaded');
}

const dataUriPromise = new Promise<string>((resolve, reject) => {
base_image.onload = () => {
// TODO: resize image
canvas.width = base_image.naturalWidth;
canvas.height = base_image.naturalHeight;
/**
* Transform the preview to clean it up and embed images
*/
private async transformHTML(element: HTMLElement): Promise<HTMLElement> {
// Remove styling which forces the preview to fill the window vertically
// @ts-ignore
const node: HTMLElement = element.cloneNode(true);
node.style.paddingBottom = '0';
node.style.minHeight = '0';

ctx!.drawImage(base_image, 0, 0);
this.removeIndicators(node);

try {
const uri = canvas.toDataURL('image/png');
console.log(`converting done: ${uri.substring(0, 40)} (${canvas.height}x${canvas.width}`);
resolve(uri);
}
catch (err) {
console.log(`feiled ${url}`, err);
// leave original url
// images from plantuml.com cannot be loaded this way (tainted)
resolve(url);
// reject(err);
}
await this.embedImages(node);

canvas.remove();
}
})
return node;
}

base_image.src = url;
return dataUriPromise;
}
/** Remove the collapse indicators from HTML, not needed (and not working) in copy */
private removeIndicators(node: HTMLElement) {
node.querySelectorAll('.collapse-indicator')
.forEach(node => node.remove());
}

function cloneElement(element: HTMLElement): HTMLElement {
// @ts-ignore
return element.cloneNode(true);
}
/** Replace all images sources with a data-uri */
private async embedImages(node: HTMLElement): Promise<HTMLElement> {
const promises: Promise<void>[] = [];

async function replaceImageSource(image: HTMLImageElement): Promise<void> {
image.src = await imageToUri(image.src);
}
node.querySelectorAll('img')
.forEach(img => {
console.log('data', img.src);
if (img.src && !img.src.startsWith('data:')) {
promises.push(this.replaceImageSource(img));
}
});

async function embedImages(element: HTMLElement): Promise<HTMLElement> {
const node = cloneElement(element);
// @ts-ignore
this.modal.progress.max = 100;

const promises: Promise<void>[] = [];
// @ts-ignore
await allWithProgress(promises, percentCompleted => this.modal.progress.value = percentCompleted);
return node;
}

node.querySelectorAll('img')
.forEach(img => {
if (img.src && !img.src.startsWith('data:')) {
promises.push(replaceImageSource(img));
/** replace image src attribute with data uri */
private async replaceImageSource(image: HTMLImageElement): Promise<void> {
image.src = await this.imageToDataUri(image.src);
}

/**
* Draw image url to canvas and return as data uri containing image pixel data
*/
private async imageToDataUri(url: string): Promise<string> {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');

const base_image = new Image();
base_image.setAttribute('crossOrigin', 'anonymous');

const dataUriPromise = new Promise<string>((resolve, reject) => {
base_image.onload = () => {
// TODO: resize image
canvas.width = base_image.naturalWidth;
canvas.height = base_image.naturalHeight;

ctx!.drawImage(base_image, 0, 0);

try {
const uri = canvas.toDataURL('image/png');
resolve(uri);
} catch (err) {
console.log(`failed ${url}`, err);
// if we fail, leave the original url
// images from plantuml.com cannot be loaded this way (tainted), but could still be accessed
// from outside
resolve(url);
}

canvas.remove();
}
});
})

await Promise.all(promises);
return node;
base_image.src = url;
return dataUriPromise;
}
}

export default class CopyAsHTMLPlugin extends Plugin {
Expand All @@ -74,17 +174,18 @@ export default class CopyAsHTMLPlugin extends Plugin {
name: 'Copy current document to clipboard',

editorCallback: async (editor: Editor, view: MarkdownView) => {
const copier = new HTMLConverter(view, this.app);

try {
// @ts-ignore
const topNode: HTMLElement = view.contentEl.querySelector('.markdown-reading-view .markdown-preview-section')
let document = await embedImages(topNode!);
const document = await copier.documentToHTML();

const data =
new ClipboardItem({
"text/html": new Blob([document.outerHTML], {
type: "text/html"
// @ts-ignore
type: ["text/html", 'text/plain']
}),
"text/plain": new Blob([document.innerText], {
"text/plain": new Blob([document.outerHTML], {
type: "text/plain"
}),
});
Expand All @@ -100,3 +201,30 @@ export default class CopyAsHTMLPlugin extends Plugin {
});
}
}

/**
* Modal to show progress during conversion
*/
class CopyingToHtmlModal extends Modal {
private _progress: HTMLElement;

constructor(app: App) {
super(app);
}

get progress() {
return this._progress;
}

onOpen() {
let {titleEl, contentEl} = this;
titleEl.setText('Copying to clipboard');
this._progress = contentEl.createEl('progress');
this._progress.style.width = '100%';
}

onClose() {
let {contentEl} = this;
contentEl.empty();
}
}
4 changes: 2 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"id": "copy-as-html",
"name": "Copy as HTML",
"version": "0.0.1",
"version": "0.0.2",
"minAppVersion": "1.0.0",
"description": "Copy the current document as HTML so it can be pasted into HTML aware applications like gmail.",
"description": "Copy the current document to clipboard as HTML",
"author": "mvdkwast",
"authorUrl": "https://github.com/mvdkwast",
"isDesktopOnly": false
Expand Down

0 comments on commit b4dab1a

Please sign in to comment.