diff --git a/ea-scripts/Shade Master.md b/ea-scripts/Shade Master.md index cbfee91..3ecbf06 100644 --- a/ea-scripts/Shade Master.md +++ b/ea-scripts/Shade Master.md @@ -1,18 +1,58 @@ /* -This script modifies the color lightness/hue/saturation/transparency of selected Excalidraw elements. -Select elements in the scene, then run the script. +This is an experimental script. If you find bugs, please consider debugging yourself then submitting a PR on github with the fix, instead of raising an issue. Thank you! +This script modifies the color lightness/hue/saturation/transparency of selected Excalidraw elements and SVG and nested Excalidraw drawings. Select eligible elements in the scene, then run the script. + +- The color of Excalidraw elements (lines, ellipses, rectangles, etc.) will be changed by the script. +- The color of SVG elements and nested Excalidraw drawings will only be mapped. When mapping colors, the original image remains unchanged, only a mapping table is created and the image is recolored during rendering of your Excalidraw screen. In case you want to make manual changes you can also edit the mapping in Markdown View Mode under `## Embedded Files` + +If you select only a single SVG or nested Excalidraw element, then the script offers an additional feature. You can map colors one by one in the image. ```js*/ -if(!ea.verifyMinimumPluginVersion || !ea.verifyMinimumPluginVersion("1.7.19")) { + +const HELP_TEXT = ` +- Select SVG images, nested Excalidraw drawings and/or regular Excalidraw elements +- For a single selected image, you can map colors individually in the color mapping section +- For Excalidraw elements: stroke and background colors are modified permanently +- For SVG/nested drawings: original files stay unchanged, color mapping is stored under \`## Embedded Files\` +- Using color maps helps maintain links between drawings while allowing different color themes +- Sliders work on relative scale - the amount of change is applied to current values +- Unlike Excalidraw's opacity setting which affects the whole element: + - Shade Master can set different opacity for stroke vs background + - Note: SVG/nested drawing colors are mapped at color name level, thus "black" is different from "#000000" + - Additionally if the same color is used as fill and stroke the color can only be changed once +- This is an experimental script - contributions welcome on GitHub via PRs +`; + +if(!ea.verifyMinimumPluginVersion || !ea.verifyMinimumPluginVersion("2.7.3")) { new Notice("This script requires a newer version of Excalidraw. Please install the latest version."); return; } +/* +SVGColorInfo is returned by ea.getSVGColorInfoForImgElement. Color info will all the color strings in the SVG file plus "fill" which represents the default fill color for SVG icons set at the SVG root element level. Fill if not set defaults to black: + +type SVGColorInfo = Map; + +In the Excalidraw file under `## Embedded Files` the color map is included after the file. That color map implements ColorMap. ea.updateViewSVGImageColorMap takes a ColorMap as input. +interface ColorMap { + [color: string]: string; +}; +*/ + // Main script execution const allElements = ea.getViewSelectedElements(); -const svgImageElements = allElements.filter(el => - el.type === "image" && ea.getViewFileForImageElement(el)?.extension === "svg" -); +const svgImageElements = allElements.filter(el => { + const file = ea.getViewFileForImageElement(el); + if(!file) return false; + return el.type === "image" && ( + file.extension === "svg" || + ea.isExcalidrawFile(file) + ); +}); if(allElements.length === 0) { new Notice("Select at least one rectangle, ellipse, diamond, line, arrow, freedraw, text or SVG image elment"); @@ -21,6 +61,7 @@ if(allElements.length === 0) { const originalColors = new Map(); const colorInputs = new Map(); +const sliderResetters = []; let terminate = false; const FORMAT = "Color Format"; const STROKE = "Modify Stroke Color"; @@ -41,6 +82,15 @@ if(!settings[STROKE]) { ea.setScriptSettings(settings); } +function getRegularElements() { + ea.clear(); + //loading view elements again as element objects change when colors are updated + const allElements = ea.getViewSelectedElements(); + return allElements.filter(el => + ["rectangle", "ellipse", "diamond", "line", "arrow", "freedraw", "text"].includes(el.type) + ); +} + const updatedImageElementColorMaps = new Map(); let isWaitingForSVGUpdate = false; function updateViewImageColors() { @@ -52,24 +102,22 @@ function updateViewImageColors() { colorMapArray = Array.from(updatedImageElementColorMaps.values()); updatedImageElementColorMaps.clear(); ea.updateViewSVGImageColorMap(elementArray, colorMapArray).then(()=>{ + console.log("Updated colors", elementArray, colorMapArray); isWaitingForSVGUpdate = false; updateViewImageColors(); }); } async function storeOriginalColors() { - // Store colors for regular elements - const regularElements = allElements.filter(el => - ["rectangle", "ellipse", "diamond", "line", "arrow", "freedraw", "text"].includes(el.type) - ); - - for (const el of regularElements) { + // Store colors for regular elements + for (const el of getRegularElements()) { const key = el.id; - originalColors.set(key, { + const colorData = { type: "regular", strokeColor: el.strokeColor, backgroundColor: el.backgroundColor - }); + }; + originalColors.set(key, colorData); } // Store colors for SVG elements @@ -79,22 +127,56 @@ async function storeOriginalColors() { for (const [color, info] of colorInfo.entries()) { svgColors.set(color, {...info}); } - originalColors.set(el.id, { + const svgColorData = { type: "svg", colors: svgColors - }); + }; + originalColors.set(el.id, svgColorData); } } +function clearSVGMapping() { + for (const resetter of sliderResetters) { + resetter(); + } + // Reset SVG elements + if (svgImageElements.length === 1) { + const el = svgImageElements[0]; + const original = originalColors.get(el.id); + if (original && original.type === "svg") { + + for (const color of original.colors.keys()) { + // Update UI components + const inputs = colorInputs.get(color); + if (inputs) { + if(color === "fill") { + //stroke is a special value in case the SVG has no fill color defined (i.e black) + inputs.textInput.setValue("black"); + inputs.colorPicker.setValue("#000000"); + } else { + const cm = ea.getCM(color); + inputs.textInput.setValue(color); + inputs.colorPicker.setValue(cm.stringHEX({alpha: false}).toLowerCase()); + } + } + } + updatedImageElementColorMaps.set(el, {}); + } + } else { + for (const el of svgImageElements) { + updatedImageElementColorMaps.set(el, {}); + } + } + updateViewImageColors(); +} + // Function to reset colors async function resetColors() { - ea.clear(); - const allElements = ea.getViewSelectedElements(); - - // Reset regular elements - const regularElements = allElements.filter(el => - ["rectangle", "ellipse", "diamond", "line", "arrow", "freedraw", "text"].includes(el.type) - ); + for (const resetter of sliderResetters) { + resetter(); + } + + const regularElements = getRegularElements(); if (regularElements.length > 0) { ea.copyViewElementsToEAforEditing(regularElements); @@ -114,55 +196,29 @@ async function resetColors() { const original = originalColors.get(el.id); if (original && original.type === "svg") { const newColorMap = {}; - let hasChanges = false; - const currentColors = await ea.getSVGColorInfoForImgElement(el); - for (const [color, info] of currentColors.entries()) { - const originalInfo = original.colors.get(color); - if (originalInfo) { - newColorMap[color] = originalInfo.mappedTo; - hasChanges = true; - - // Update UI components - const inputs = colorInputs.get(color); - if (inputs) { - const cm = ea.getCM(originalInfo.mappedTo); - inputs.textInput.setValue(originalInfo.mappedTo); - inputs.colorPicker.setValue(cm.stringHEX({alpha: false})); - } + for (const [color, info] of original.colors.entries()) { + newColorMap[color] = info.mappedTo; + // Update UI components + const inputs = colorInputs.get(color); + if (inputs) { + const cm = ea.getCM(info.mappedTo); + inputs.textInput.setValue(info.mappedTo); + inputs.colorPicker.setValue(cm.stringHEX({alpha: false}).toLowerCase()); } } - - if (hasChanges) { - updatedImageElementColorMaps.set(el, newColorMap); - } else { - updatedImageElementColorMaps.delete(el); - } + updatedImageElementColorMaps.set(el, newColorMap); } } else { - for (const el of allElements.filter(el => - el.type === "image" && ea.getViewFileForImageElement(el)?.extension === "svg" - )) { + for (const el of svgImageElements) { const original = originalColors.get(el.id); if (original && original.type === "svg") { const newColorMap = {}; - let hasChanges = false; - const currentColors = await ea.getSVGColorInfoForImgElement(el); - for (const [color, info] of currentColors.entries()) { - const originalInfo = original.colors.get(color); - if (originalInfo) { - newColorMap[color] = originalInfo.mappedTo; - hasChanges = true; - } - } - - if (hasChanges) { - //using el.id as key as elements may change - updatedImageElementColorMaps.set(el, newColorMap); - } else { - updatedImageElementColorMaps.delete(el); - } + for (const [color, info] of original.colors.entries()) { + newColorMap[color] = info.mappedTo; + } + updatedImageElementColorMaps.set(el, newColorMap); } } } @@ -196,32 +252,44 @@ function modifyColor(color, isDecrease, step, action) { const format = settings[FORMAT].value; switch(format) { - case "RGB": return modified.stringRGB(opts); - case "HEX": return modified.stringHEX(opts); - default: return modified.stringHSL(opts); + case "RGB": return modified.stringRGB(opts).toLowerCase(); + case "HEX": return modified.stringHEX(opts).toLowerCase(); + default: return modified.stringHSL(opts).toLowerCase(); } } function slider(contentEl, action, min, max, step, invert) { let prevValue = (max-min)/2; + let debounce = false; + let sliderControl; new ea.obsidian.Setting(contentEl) .setName(action) - .addSlider(slider => slider - .setLimits(min, max, step) - .setValue(prevValue) - .onChange(async (value) => { - const isDecrease = invert ? value > prevValue : value < prevValue; - const step = Math.abs(value-prevValue); - prevValue = value; - if(step>0) { - await run(isDecrease, step, action); - } - }), + .addSlider(slider => { + sliderControl = slider; + slider + .setLimits(min, max, step) + .setValue(prevValue) + .onChange(async (value) => { + if (debounce) return; + const isDecrease = invert ? value > prevValue : value < prevValue; + const step = Math.abs(value-prevValue); + prevValue = value; + if(step>0) { + run(isDecrease, step, action); + } + }); + } ); + return () => { + debounce = true; + prevValue = (max-min)/2; + sliderControl.setValue(prevValue); + debounce = false; + } } function showModal() { - let isColorPickerChange = false; + let debounceColorPicker = true; const modal = new ea.obsidian.Modal(app); let dirty = false; @@ -230,6 +298,14 @@ function showModal() { modal.bgOpacity = 0; contentEl.createEl('h2', { text: 'Shade Master' }); + const helpDiv = contentEl.createEl("details", { + attr: { style: "margin-bottom: 1em;background: var(--background-secondary); padding: 1em; border-radius: 4px;" }}); + helpDiv.createEl("summary", { text: "Help & Usage Guide", attr: { style: "cursor: pointer; color: var(--text-accent);" } }); + helpDiv.createEl("div", { + text: HELP_TEXT, + attr: { style: "margin-top: 0.5em; white-space: pre-wrap;" } + }); + new ea.obsidian.Setting(contentEl) .setName(FORMAT) .setDesc("Output color format") @@ -266,10 +342,10 @@ function showModal() { }) ); - slider(contentEl, "Hue", 0, 400, 1, false); - slider(contentEl, "Saturation", 0, 200, 1, false); - slider(contentEl, "Lightness", 0, 50, 1, false); - slider(contentEl, "Transparency", 0, 1, 0.05, true); + sliderResetters.push(slider(contentEl, "Hue", 0, 400, 1, false)); + sliderResetters.push(slider(contentEl, "Saturation", 0, 200, 1, false)); + sliderResetters.push(slider(contentEl, "Lightness", 0, 100, 1, false)); + sliderResetters.push(slider(contentEl, "Transparency", 0, 2, 0.05, true)); // Add color pickers if a single SVG image is selected if (svgImageElements.length === 1) { @@ -281,7 +357,7 @@ function showModal() { for (const [color, info] of colorInfo.entries()) { const row = new ea.obsidian.Setting(colorSection) - .setName(color) + .setName(color === "fill" ? "SVG default" : color) .setDesc(`${info.fill ? "Fill" : ""}${info.fill && info.stroke ? " & " : ""}${info.stroke ? "Stroke" : ""}`); // Create color preview div @@ -289,50 +365,58 @@ function showModal() { previewDiv.style.width = "30px"; previewDiv.style.height = "20px"; previewDiv.style.border = "1px solid var(--background-modifier-border)"; - //previewDiv.style.marginRight = "10px"; - previewDiv.style.backgroundColor = ea.getCM(color).stringHEX({alpha: false}); - - // Add reset button + if (color === "transparent") { + previewDiv.style.backgroundImage = "linear-gradient(45deg, #808080 25%, transparent 25%), linear-gradient(-45deg, #808080 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #808080 75%), linear-gradient(-45deg, transparent 75%, #808080 75%)"; + previewDiv.style.backgroundSize = "10px 10px"; + previewDiv.style.backgroundPosition = "0 0, 0 5px, 5px -5px, -5px 0px"; + } else { + previewDiv.style.backgroundColor = ea.getCM(color).stringHEX({alpha: false}).toLowerCase(); + } const resetButton = new ea.obsidian.Setting(row.controlEl) .addButton(button => button .setButtonText(">>>") - .setClass("reset-color-button") // Optional: for styling + .setClass("reset-color-button") .onClick(async () => { const original = originalColors.get(svgElement.id); if (original?.type === "svg") { const originalInfo = original.colors.get(color); if (originalInfo) { - const newColorMap = ea.getColorMapForImageElement(svgElement); + const newColorMap = await ea.getColorMapForImageElement(svgElement); delete newColorMap[color]; updatedImageElementColorMaps.set(svgElement, newColorMap); updateViewImageColors(); // Update UI components - isColorPickerChange = false; - textInput.setValue(color); - colorPicker.setValue(ea.getCM(color).stringHEX({alpha: false})); + debounceColorPicker = true; + textInput.setValue(color === "fill" ? "black" : color); + colorPicker.setValue(color === "fill" + ? "#000000" + : ea.getCM(color).stringHEX({alpha: false}).toLowerCase() + ); updateViewImageColors(); } } })) resetButton.settingEl.style.padding = "0"; resetButton.settingEl.style.border = "0"; - //resetButton.settingEl.style.marginRight = "5px"; // Add text input for color value const textInput = new ea.obsidian.TextComponent(row.controlEl) .setValue(info.mappedTo) .setPlaceholder("Color value"); textInput.inputEl.style.width = "120px"; - //textInput.inputEl.style.marginRight = "5px"; + textInput.onChange(value => { + const lower = value.toLowerCase(); + if (lower === color) return; + textInput.setValue(lower); + }) const applyButtonComponent = new ea.obsidian.Setting(row.controlEl) .addButton(button => button .setIcon("check") .setTooltip("Apply") .onClick(async () => { - debugger; const value = textInput.getValue(); try { if(!CSS.supports("color",value)) { @@ -341,17 +425,15 @@ function showModal() { } const cm = ea.getCM(value); if (cm) { - // Preserve alpha from original color if new color is valid - const originalAlpha = ea.getCM(info.mappedTo).alpha; const format = settings[FORMAT].value; const alpha = cm.alpha < 1 ? true : false; const newColor = format === "RGB" - ? cm.stringRGB({alpha , precision: [1,2,2,3]}) + ? cm.stringRGB({alpha , precision: [1,2,2,3]}).toLowerCase() : format === "HEX" - ? cm.stringHEX({alpha}) - : cm.stringHSL({alpha, precision: [1,2,2,3]}); + ? cm.stringHEX({alpha}).toLowerCase() + : cm.stringHSL({alpha, precision: [1,2,2,3]}).toLowerCase(); - const newColorMap = ea.getColorMapForImageElement(svgElement); + const newColorMap = await ea.getColorMapForImageElement(svgElement); if(color === newColor) { delete newColorMap[color]; } else { @@ -359,8 +441,8 @@ function showModal() { } updatedImageElementColorMaps.set(svgElement, newColorMap); updateViewImageColors(); - isColorPickerChange = false; - colorPicker.setValue(cm.stringHEX({alpha: false})); + debounceColorPicker = true; + colorPicker.setValue(cm.stringHEX({alpha: false}).toLowerCase()); } } catch (e) { console.error("Invalid color value:", e); @@ -371,7 +453,7 @@ function showModal() { // Add color picker const colorPicker = new ea.obsidian.ColorComponent(row.controlEl) - .setValue(ea.getCM(info.mappedTo).stringHEX({alpha: false})); + .setValue(ea.getCM(info.mappedTo).stringHEX({alpha: false}).toLowerCase()); // Store references to the components colorInputs.set(color, { @@ -382,12 +464,12 @@ function showModal() { }); colorPicker.colorPickerEl.addEventListener('click', () => { - isColorPickerChange = true; + debounceColorPicker = false; }); colorPicker.onChange(async (value) => { try { - if(isColorPickerChange) { + if(!debounceColorPicker) { // Preserve alpha from original color const originalAlpha = ea.getCM(info.mappedTo).alpha; const cm = ea.getCM(value); @@ -395,16 +477,16 @@ function showModal() { const alpha = originalAlpha < 1 ? true : false; const format = settings[FORMAT].value; const newColor = format === "RGB" - ? cm.stringRGB({alpha, precision: [1,2,2,3]}) + ? cm.stringRGB({alpha, precision: [1,2,2,3]}).toLowerCase() : format === "HEX" - ? cm.stringHEX({alpha}) - : cm.stringHSL({alpha, precision: [1,2,2,3]}); + ? cm.stringHEX({alpha}).toLowerCase() + : cm.stringHSL({alpha, precision: [1,2,2,3]}).toLowerCase(); // Update text input textInput.setValue(newColor); // Update SVG - const newColorMap = ea.getColorMapForImageElement(svgElement); + const newColorMap = await ea.getColorMapForImageElement(svgElement); if(color === newColor) { delete newColorMap[color]; } else { @@ -416,26 +498,36 @@ function showModal() { } catch (e) { console.error("Invalid color value:", e); } finally { - isColorPickerChange = false; + debounceColorPicker = true; } }); } } - - - new ea.obsidian.Setting(contentEl) + + const buttons = new ea.obsidian.Setting(contentEl); + if(svgImageElements.length > 0) { + buttons.addButton(button => button + .setButtonText("Initialize SVG Colors") + .onClick(() => { + debounceColorPicker = true; + clearSVGMapping(); + }) + ); + } + + buttons .addButton(button => button - .setButtonText("Reset Colors") - .onClick(async () => { - isColorPickerChange = false; - await resetColors(); + .setButtonText("Reset") + .onClick(() => { + debounceColorPicker = true; + resetColors(); })) .addButton(button => button .setButtonText("Close") .setCta(true) .onClick(() => modal.close())); - makeModalDraggable(modal.modalEl); // Add draggable functionality + makeModalDraggable(modal.modalEl); }; modal.onClose = () => { @@ -462,7 +554,7 @@ function makeModalDraggable(modalEl) { const header = modalEl.querySelector('.modal-titlebar') || modalEl; // Default to modalEl if no titlebar header.style.cursor = 'move'; - const onMouseDown = (e) => { + const onPointerDown = (e) => { // Ensure the event target isn't an interactive element like slider, button, or input if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON') return; @@ -474,12 +566,12 @@ function makeModalDraggable(modalEl) { initialY = rect.top; modalEl.style.position = 'absolute'; - modalEl.style.margin = '0'; // Reset margin to avoid issues + modalEl.style.margin = '0'; modalEl.style.left = `${initialX}px`; modalEl.style.top = `${initialY}px`; }; - const onMouseMove = (e) => { + const onPointerMove = (e) => { if (!isDragging) return; const dx = e.clientX - startX; @@ -489,57 +581,28 @@ function makeModalDraggable(modalEl) { modalEl.style.top = `${initialY + dy}px`; }; - const onMouseUp = () => { + const onPointerUp = () => { isDragging = false; }; - header.addEventListener('mousedown', onMouseDown); - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); + header.addEventListener('pointerdown', onPointerDown); + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', onPointerUp); // Clean up event listeners on modal close modalEl.addEventListener('remove', () => { - header.removeEventListener('mousedown', onMouseDown); - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); + header.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); }); } -isRunning = false; -const queue = []; -function processQueue() { - if (!isRunning && queue.length > 0) { - const [isDecrease, step, action] = queue.shift(); - executeChange(isDecrease, step, action).then(() => { - updateViewImageColors() - if (!terminate && queue.length > 0) processQueue(); - }); - } -} - -const MAX_QUEUE_SIZE = 100; -function run(isDecrease, step, action) { - if (queue.length >= MAX_QUEUE_SIZE) { - new Notice ("Queue overflow. Dropping task."); - return; - } - queue.push([isDecrease, step, action]); - if (!isRunning) processQueue(); -} - async function executeChange(isDecrease, step, action) { try { isRunning = true; - - ea.clear(); const modifyStroke = settings[STROKE].value; const modifyBackground = settings[BACKGROUND].value; - - //must reselect after each run since elements change in the scene - const allElements = ea.getViewSelectedElements(); - const regularElements = allElements.filter(el => - ["rectangle", "ellipse", "diamond", "line", "arrow", "freedraw", "text"].includes(el.type) - ); + const regularElements = getRegularElements(); // Process regular elements if (regularElements.length > 0) { @@ -561,7 +624,6 @@ async function executeChange(isDecrease, step, action) { const el = svgImageElements[0]; const colorInfo = await ea.getSVGColorInfoForImgElement(el); const newColorMap = {}; - let hasChanges = false; // Process each color in the SVG for (const [color, info] of colorInfo.entries()) { @@ -571,32 +633,22 @@ async function executeChange(isDecrease, step, action) { const modifiedColor = modifyColor(info.mappedTo, isDecrease, step, action); if (modifiedColor !== color) { newColorMap[color] = modifiedColor; - hasChanges = true; - - // Update UI components if they exist - const inputs = colorInputs.get(color); - if (inputs) { - const cm = ea.getCM(modifiedColor); - inputs.textInput.setValue(modifiedColor); - inputs.colorPicker.setValue(cm.stringHEX({alpha: false})); - } + } + // Update UI components if they exist + const inputs = colorInputs.get(color); + if (inputs) { + const cm = ea.getCM(modifiedColor); + inputs.textInput.setValue(modifiedColor); + inputs.colorPicker.setValue(cm.stringHEX({alpha: false}).toLowerCase()); } } } - - // Update the SVG if any colors were modified - if (hasChanges) { - updatedImageElementColorMaps.set(el, newColorMap); - } else { - updatedImageElementColorMaps.delete(el); - } + updatedImageElementColorMaps.set(el, newColorMap); } else { if (svgImageElements.length > 0) { for (const el of svgImageElements) { - // Get current color mapping const colorInfo = await ea.getSVGColorInfoForImgElement(el); const newColorMap = {}; - let hasChanges = false; // Process each color in the SVG for (const [color, info] of colorInfo.entries()) { @@ -606,18 +658,10 @@ async function executeChange(isDecrease, step, action) { const modifiedColor = modifyColor(info.mappedTo, isDecrease, step, action); if (modifiedColor !== color) { newColorMap[color] = modifiedColor; - hasChanges = true; } } } - - // Update the SVG if any colors were modified - if (hasChanges) { - //using el.id as key as elements may change - updatedImageElementColorMaps.set(el, newColorMap); - } else { - updatedImageElementColorMaps.delete(el); - } + updatedImageElementColorMaps.set(el, newColorMap); } } } @@ -629,6 +673,28 @@ async function executeChange(isDecrease, step, action) { } } +isRunning = false; +const queue = []; +function processQueue() { + if (!terminate && !isRunning && queue.length > 0) { + const [isDecrease, step, action] = queue.shift(); + executeChange(isDecrease, step, action).then(() => { + updateViewImageColors() + if (queue.length > 0) processQueue(); + }); + } +} + +const MAX_QUEUE_SIZE = 100; +function run(isDecrease, step, action) { + if (queue.length >= MAX_QUEUE_SIZE) { + new Notice ("Queue overflow. Dropping task."); + return; + } + queue.push([isDecrease, step, action]); + if (!isRunning) processQueue(); +} + await storeOriginalColors(); showModal(); processQueue(); \ No newline at end of file diff --git a/src/lang/locale/en.ts b/src/lang/locale/en.ts index be6e1e1..b1a525c 100644 --- a/src/lang/locale/en.ts +++ b/src/lang/locale/en.ts @@ -30,6 +30,7 @@ export default { "Script is up to date - Click to reinstall", OPEN_AS_EXCALIDRAW: "Open as Excalidraw Drawing", TOGGLE_MODE: "Toggle between Excalidraw and Markdown mode", + DUPLICATE_IMAGE: "Duplicate selected image with a different image ID", CONVERT_NOTE_TO_EXCALIDRAW: "Convert markdown note to Excalidraw Drawing", CONVERT_EXCALIDRAW: "Convert *.excalidraw to *.md files", CREATE_NEW: "Create new drawing",