Skip to content

Commit 9162efe

Browse files
committed
feat: experimental photopea plugin
1 parent 547a5e3 commit 9162efe

File tree

10 files changed

+328
-0
lines changed

10 files changed

+328
-0
lines changed
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/*\
2+
title: $:/plugins/sq/photopea/edit-photopea.js
3+
type: application/javascript
4+
module-type: widget
5+
6+
Edit-bitmap widget
7+
8+
\*/
9+
(function(){
10+
11+
/*jslint node: true, browser: true */
12+
/*global $tw: false */
13+
14+
let Widget = require("$:/core/modules/widgets/widget.js").widget;
15+
let Photopea = require("$:/plugins/sq/photopea/photopea.min.js");
16+
let DEFAULT_IMAGE_TYPE = "image/png";
17+
18+
let EditPhotopeaWidget = function(parseTreeNode,options) {
19+
this.initialise(parseTreeNode,options);
20+
};
21+
22+
function base64ToArrayBuffer(base64) {
23+
let binaryString = atob(base64),
24+
bytes = new Uint8Array(binaryString.length);
25+
for (let i = 0; i < binaryString.length; i++) {
26+
bytes[i] = binaryString.charCodeAt(i);
27+
}
28+
return bytes.buffer;
29+
}
30+
31+
function arrayBufferToBase64( buffer ) {
32+
let binary = '',
33+
bytes = new Uint8Array( buffer ),
34+
len = bytes.byteLength;
35+
for (let i = 0; i < len; i++) {
36+
binary += String.fromCharCode( bytes[ i ] );
37+
}
38+
return window.btoa( binary );
39+
}
40+
41+
/*
42+
Inherit from the base widget class
43+
*/
44+
EditPhotopeaWidget.prototype = new Widget();
45+
46+
/*
47+
Render this widget into the DOM
48+
*/
49+
EditPhotopeaWidget.prototype.render = function(parent,nextSibling) {
50+
let self = this;
51+
this.window = self.document.parentWindow || self.document.defaultView;
52+
// Save the parent dom node
53+
this.parentDomNode = parent;
54+
// Compute our attributes
55+
this.computeAttributes();
56+
// Execute our logic
57+
this.execute();
58+
// Create the wrapper for the toolbar and render its content
59+
this.container = this.document.createElement("div");
60+
this.container.className = "photopea-container";
61+
this.container.style.height = "850px";
62+
// // Insert the elements into the DOM
63+
parent.insertBefore(this.container,nextSibling);
64+
this.renderChildren(this.container,null);
65+
this.domNodes.push(this.container);
66+
//set up the iframe and initalize Photopea
67+
this.init();
68+
};
69+
70+
EditPhotopeaWidget.prototype.getImageType = function() {
71+
let tiddler = this.wiki.getTiddler(this.editTitle),
72+
type = tiddler?.fields?.type || DEFAULT_IMAGE_TYPE,
73+
extension = $tw.config.contentTypeInfo[type].extension;
74+
return extension.startsWith('.') ? extension.slice(1) : extension;
75+
};
76+
77+
EditPhotopeaWidget.prototype.init = async function() {
78+
let self = this,
79+
iframeLoaded = false,
80+
pendingSave = false,
81+
pendingExit = false,
82+
photopeaWindow;
83+
84+
if(!$tw.browser) {
85+
return;
86+
}
87+
88+
function onMessage(e) {
89+
console.log(e);
90+
if(Object.prototype.toString.call(e.data) === "[object ArrayBuffer]") {
91+
//save image
92+
self.saveChanges(e.data);
93+
}
94+
if(e.data === "saved" && pendingSave) {
95+
pendingSave = false;
96+
if(pendingExit) {
97+
if(self.exitActions) {
98+
self.invokeActionString(self.exitActions);
99+
}
100+
}
101+
}
102+
}
103+
window.addEventListener("message", onMessage);
104+
105+
try {
106+
let language = self.wiki.getTiddlerText("$:/language").split("-")[0].slice("$:/languages/".length),
107+
pea = await Photopea.createEmbed(this.container, {
108+
"environment": {
109+
"customIO": {
110+
"save": `if(!app.activeDocument.saved)app.activeDocument.saveToOE("${self.getImageType()}");`,
111+
//TODO:, saveToOE has second part to the argument for quality that defaults to 0.7 https://www.photopea.com/learn/scripts#:~:text=Document.saveToOE(%22png%22)
112+
"exportAs":true,
113+
},
114+
"lang": `${language}`,
115+
"menus": [[0,0,0,0,0,1,0,0,1],1,1,1,1,1,1,1,1,1],
116+
/*"autosave" : 5*/
117+
}
118+
},self.window);
119+
120+
iframeLoaded = true;
121+
photopeaWindow = self.container.getElementsByTagName("iframe")[0].contentWindow;
122+
if(self.wiki.getTiddler(self.editTitle)) {
123+
let base64String = self.wiki.getTiddlerText(self.editTitle);
124+
//send the image to the iframe
125+
photopeaWindow.postMessage(base64ToArrayBuffer(base64String),"*");
126+
}
127+
} catch(err) {
128+
let errorMessage = self.document.createElement("span");
129+
errorMessage.className = "tc-error";
130+
errorMessage.textContent = `Error loading Photopea: ${err}`;
131+
self.container.append(errorMessage);
132+
} finally {
133+
self.addEventListener("tm-photopea-save",function(event){
134+
if(iframeLoaded) {
135+
pendingExit = true;
136+
pendingSave = true;
137+
let script = `app.activeDocument.saveToOE("${self.getImageType()}");app.echoToOE("saved");`;
138+
photopeaWindow.postMessage(script,"*");
139+
//pea.runScript(script);
140+
} else {
141+
// make sure we can still exit
142+
if(self.exitActions) {
143+
self.invokeActionString(self.exitActions);
144+
}
145+
}
146+
});
147+
}
148+
};
149+
150+
/*
151+
Compute the internal state of the widget
152+
*/
153+
EditPhotopeaWidget.prototype.execute = function() {
154+
// Get our parameters
155+
this.editTitle = this.getAttribute("tiddler",this.getVariable("currentTiddler"));
156+
this.exitActions = this.getAttribute("exitactions");
157+
// Make the child widgets
158+
this.makeChildWidgets();
159+
};
160+
161+
function getFileType(arrayBuffer) {
162+
// Convert the ArrayBuffer to a Uint8Array
163+
const bytes = new Uint8Array(arrayBuffer);
164+
165+
// Convert the first few bytes to a hexadecimal string
166+
const hexSignature = bytes.slice(0, 8).reduce((acc, byte) => acc + byte.toString(16).padStart(2, '0'), '');
167+
168+
// Known file signatures
169+
const signatures = {
170+
'ffd8ffe0': 'JPEG', // JPEG file signature
171+
'ffd8ffe1': 'JPEG', // Additional JPEG signature
172+
'ffd8ffe2': 'JPEG', // Additional JPEG signature
173+
'89504e47': 'PNG', // PNG file signature
174+
'47494638': 'GIF', // GIF file signature
175+
'49492a00': 'TIFF', // TIFF (little-endian)
176+
'4d4d002a': 'TIFF', // TIFF (big-endian)
177+
'52494646': 'WEBP', // RIFF header for WEBP
178+
'00000020': 'ISO Base Media', // Base signature for ISO formats (AVIF included)
179+
};
180+
181+
// Match the signature for basic formats
182+
const basicMatch = signatures[hexSignature.slice(0, 8)];
183+
if (basicMatch) {
184+
if (basicMatch === 'ISO Base Media') {
185+
// Check additional bytes for AVIF
186+
const avifSignature = String.fromCharCode(...bytes.slice(4, 12));
187+
if (avifSignature.includes('avif')) {
188+
return '.avif';
189+
}
190+
}
191+
return `.${basicMatch}`.toLowerCase();
192+
}
193+
194+
// SVG detection (based on XML declaration or <svg> tag)
195+
const text = new TextDecoder().decode(arrayBuffer);
196+
if (text.trim().startsWith('<?xml') || text.includes('<svg')) {
197+
return '.svg';
198+
}
199+
200+
return null;
201+
};
202+
203+
/*
204+
Just refresh the toolbar
205+
*/
206+
EditPhotopeaWidget.prototype.refresh = function(changedTiddlers) {
207+
return this.refreshChildren(changedTiddlers);
208+
};
209+
210+
EditPhotopeaWidget.prototype.saveChanges = function(buffer) {
211+
let tiddler = this.wiki.getTiddler(this.editTitle) || new $tw.Tiddler({title: this.editTitle,type: DEFAULT_IMAGE_TYPE}),
212+
//type = tiddler.fields.type || DEFAULT_IMAGE_TYPE;
213+
buffertype = $tw.config.fileExtensionInfo[getFileType(buffer)]?.type,
214+
type = buffertype || tiddler.fields.type || DEFAULT_IMAGE_TYPE,
215+
newContent = arrayBufferToBase64(buffer);
216+
217+
if(!tiddler.fields.text || newContent != tiddler.fields.text) {
218+
let update = {type: type, text: newContent};
219+
this.wiki.addTiddler(new $tw.Tiddler(this.wiki.getModificationFields(),tiddler,update,this.wiki.getCreationFields()));
220+
}
221+
};
222+
223+
exports["edit-photopea"] = EditPhotopeaWidget;
224+
225+
})();
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
title: $:/plugins/sq/photopea/editor-button
2+
tags: $:/tags/EditorToolbar
3+
icon: $:/plugins/sq/photopea/icon
4+
caption: Open in Photopea
5+
description: Open in Photopea
6+
condition: image/jpeg image/jpg image/png image/webp :intersection[<targetTiddler>get[type]]
7+
8+
\procedure state() $:/config/state/photopea/layout
9+
\procedure layout-config() $:/layout
10+
11+
<$action-setfield $tiddler=<<state>> old-layout={{{ [<layout-config>get[text]]}}} text=<<storyTiddler>> />
12+
<$action-setfield $tiddler=<<layout-config>> text="$:/plugins/sq/photopea/layout"/>

plugins/sq/Photopea/icon.tid

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
title: $:/plugins/sq/photopea/icon
2+
3+
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="30 12 175 174" width="175" height="174"><style>.a{fill:#18a497}</style><path fill-rule="evenodd" class="a" d="m59 12.3h117.2c15.6 0 28.2 12.7 28.2 28.3v117.2c0 15.6-12.6 28.2-28.2 28.2h-99.2l-0.4-74.8q0-0.3 0-0.6c0-28.3 22.5-51.3 50.4-51.3 16.7 0 30.3 13.8 30.3 30.8 0 17-13.6 30.8-30.3 30.8-5.6 0-10.1-4.6-10.1-10.3 0-5.6 4.5-10.2 10.1-10.2 5.6 0 10.1-4.6 10.1-10.3 0-5.6-4.5-10.2-10.1-10.2-16.7 0-30.2 13.7-30.2 30.7 0 17.1 13.5 30.8 30.2 30.8 27.9 0 50.4-22.9 50.4-51.3 0-28.3-22.5-51.3-50.4-51.3-39 0-70.6 32.1-70.6 71.8q0 0.4 0 0.7h-0.1l0.3 74.6c-14.5-1.2-25.9-13.3-25.9-28.1v-117.2c0-15.6 12.6-28.3 28.3-28.3z"/></svg>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
title: $:/plugins/sq/photopea/layout
2+
tags: $:/tags/Layout
3+
name: Photopea
4+
description: Edit images in Photopea
5+
<!-- TODO: remove this tag -->
6+
7+
\procedure state() $:/config/state/photopea/layout
8+
\procedure layout-config() $:/layout
9+
10+
\procedure save-actions()
11+
<$action-sendmessage $message="tm-photopea-save"/>
12+
\end
13+
14+
\procedure exit-actions()
15+
<$action-setfield $tiddler=<<layout-config>> text={{{ [<state>get[old-layout]!is[blank]!is[tiddler]else[$:/core/ui/PageTemplate]]}}}/>
16+
<$action-deletetiddler $tiddler=<<state>> />
17+
\end
18+
19+
<$edit-photopea tiddler={{{ [<state>get[text]] }}} exitactions=<<exit-actions>> >
20+
<$button
21+
tooltip="save changes and close editor"
22+
aria-label="save changes and close editor"
23+
class="save"
24+
actions=<<save-actions>>
25+
>
26+
{{$:/core/images/done-button}}
27+
</$button>
28+
</$edit-photopea>

plugins/sq/Photopea/photopea.min.js

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
title: $:/plugins/sq/photopea/photopea.min.js
2+
type: application/javascript
3+
module-type: library

plugins/sq/Photopea/plugin.info

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"title": "$:/plugins/sq/photopea",
3+
"name": "Photopea",
4+
"description": "Photopea image editor",
5+
"author": "Saq Imtiaz",
6+
"core-version": ">=5.2.0",
7+
"plugin-priority": 0,
8+
"list": "readme",
9+
"version": "0.1.0"
10+
}

plugins/sq/Photopea/readme.tid

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
title: $:/plugins/sq/photopea/readme
2+
3+
!!Open and edit embedded images from your wiki in the online photoeditor Photopea and seamlessly save them back to TiddlyWiki.
4+
5+
@@color:red;''Experimental plugin - no support available. Use at your own risk.''@@
6+
7+
* [[Photopea|https://www.photopea.com/]] is a powerful free add-supported photoeditor that will feel familiar to Photoshop users.
8+
* You need to be online in order to open Photopea.
9+
* However, Photopea runs entirely in your browser and does not upload your image anywhere.

plugins/sq/Photopea/styles.css

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
2+
.photopea-container {
3+
4+
position: relative;
5+
/*display: none;*/
6+
7+
iframe {
8+
position: fixed;
9+
top: 0;
10+
left: 0;
11+
width: 100vw;
12+
height: 100vh;
13+
z-index: 1201;
14+
}
15+
16+
button.save {
17+
z-index: 1202;
18+
position: fixed;
19+
top:2px;
20+
right: 2px;
21+
height: 60px;
22+
width: 60px;
23+
border: 1px solid #6e6767;
24+
border-radius: 5px;
25+
background-color: rgb(29, 34, 34);
26+
fill: whitesmoke;
27+
}
28+
29+
button.save:hover {
30+
fill: red;
31+
}
32+
33+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
title: $:/plugins/sq/photopea/styles
2+
tags: $:/tags/Stylesheet
3+
type: text/css

0 commit comments

Comments
 (0)