Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions scripts/imageEmbed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
documentParentDir,
encodeImageDestination,
imageEmbed,
resolveImageDirectory,
} from '../src/lib/utils/imageEmbed.js';
import { resolveDocumentRelativePath } from '../src/lib/utils/markdown.js';
import { readSource } from './sourceTree.js';
Expand Down Expand Up @@ -179,6 +180,33 @@ test('documentParentDir refuses a path with no directory in it', () => {
assert.equal(documentParentDir('note.md'), null);
});

test('${filename} expands to the document name, so each document can own its images', () => {
assert.equal(
resolveImageDirectory('${filename}.assets', '/home/u/notes/trip.md'),
'trip.assets',
);
assert.equal(
resolveImageDirectory('${filename}.assets', 'C:\\notes\\trip.md'),
'trip.assets',
);
// The extension comes off at the last dot; a leading dot is a name, not an
// extension.
assert.equal(resolveImageDirectory('${filename}', '/n/archive.tar.md'), 'archive.tar');
assert.equal(resolveImageDirectory('${filename}', '/n/.gitignore'), '.gitignore');
// No token, no expansion: the setting stays the literal folder name it has
// always been, and an empty one still means the default.
assert.equal(resolveImageDirectory('img', '/n/trip.md'), 'img');
assert.equal(resolveImageDirectory('', '/n/trip.md'), DEFAULT_IMAGE_DIRECTORY);
});

test('a $ in the document name is not read as a replacement pattern', () => {
// `String.replace` reads `$&` and `$'` in the *replacement* as the match and
// the text after it, so a document named `$&.md` would have written the
// literal token back out and created a folder called `${filename}.assets`.
assert.equal(resolveImageDirectory('${filename}.assets', '/n/$&.md'), '$&.assets');
assert.equal(resolveImageDirectory('${filename}', "/n/$'.md"), "$'");
});

test('Editor.svelte writes an image link through this module only', () => {
// The two call sites — paste (`save_image`) and drop (`copy_file_to_img`) —
// carried verbatim copies of this logic 300 lines apart, and the space-only
Expand All @@ -189,4 +217,13 @@ test('Editor.svelte writes an image link through this module only', () => {
assert.equal(source.includes('%20'), false, 'a hand-rolled space escape is back');
assert.match(source, /imageEmbed\(relPath\)/);
assert.equal(source.includes(`|| "${DEFAULT_IMAGE_DIRECTORY}"`), false);
// Three call sites read the image directory — paste, drop, and the path
// completion that lists the folder's contents. Expanding `${filename}` at
// two of them and not the third would offer completions out of a folder
// named after the literal token, which exists nowhere.
assert.equal(
source.includes('settings.imageDirectory ||'),
false,
'a call site resolves the image directory itself',
);
});
8 changes: 4 additions & 4 deletions scripts/imageUndoKeepsFile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const { lineEndingLabel } = await import('../src/lib/utils/tabModels.js');
const { countWords } = await import('../src/lib/utils/wordCount.js');
// The real module, not a stub: the embed these tests match against is the one
// it writes, and its escaping is pinned separately in imageEmbed.test.ts.
const { DEFAULT_IMAGE_DIRECTORY, documentParentDir, imageEmbed } = await import(
const { documentParentDir, imageEmbed, resolveImageDirectory } = await import(
'../src/lib/utils/imageEmbed.js'
);

Expand Down Expand Up @@ -238,7 +238,7 @@ type Component = {
* the statements that run are the component's own.
*/
const factorySource = ts.transpileModule(
`const __component = (invoke, settings, tabManager, monaco, editor, lineEndingLabel, countWords, DEFAULT_IMAGE_DIRECTORY, documentParentDir, imageEmbed) => {
`const __component = (invoke, settings, tabManager, monaco, editor, lineEndingLabel, countWords, resolveImageDirectory, documentParentDir, imageEmbed) => {
let wordCount = 0;
let currentLanguage = 'markdown';
let lineEnding = 'LF';
Expand Down Expand Up @@ -309,7 +309,7 @@ function createComponent(backend: Backend, editor: unknown): Component {
editor: unknown,
lineEndingLabel: unknown,
countWords: unknown,
defaultImageDirectory: unknown,
resolveImageDir: unknown,
parentDir: unknown,
embed: unknown,
) => Component;
Expand All @@ -322,7 +322,7 @@ function createComponent(backend: Backend, editor: unknown): Component {
editor,
lineEndingLabel,
countWords,
DEFAULT_IMAGE_DIRECTORY,
resolveImageDirectory,
documentParentDir,
imageEmbed,
);
Expand Down
19 changes: 13 additions & 6 deletions src/lib/components/Editor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@
type BufferLine,
} from '../utils/lineCoordinates.js';
import {
DEFAULT_IMAGE_DIRECTORY,
documentParentDir,
imageEmbed,
resolveImageDirectory,
} from '../utils/imageEmbed.js';

// Monaco is ~86% of the startup JavaScript (a 4.4 MB chunk, ~360ms of
Expand Down Expand Up @@ -685,8 +685,10 @@
tab.path.lastIndexOf("/"),
);
const parentDir = tab.path.substring(0, lastSlash);
const imgDirName =
settings.imageDirectory || DEFAULT_IMAGE_DIRECTORY;
const imgDirName = resolveImageDirectory(
settings.imageDirectory,
tab.path,
);

try {
const [currentEntries, imgEntries] = await Promise.all([
Expand Down Expand Up @@ -1945,8 +1947,10 @@
const tabPath = tabManager.activeTab.path;
const parentDir = documentParentDir(tabPath);
if (parentDir !== null) {
const imgDirName =
settings.imageDirectory || DEFAULT_IMAGE_DIRECTORY;
const imgDirName = resolveImageDirectory(
settings.imageDirectory,
tabPath,
);
const relPath = (await invoke("save_image", {
parentDir,
filename,
Expand Down Expand Up @@ -2268,7 +2272,10 @@
if (parentDir === null) return;

try {
const imgDirName = settings.imageDirectory || DEFAULT_IMAGE_DIRECTORY;
const imgDirName = resolveImageDirectory(
settings.imageDirectory,
tabPath,
);
const relPath = (await invoke("copy_file_to_img", {
srcPath: path,
parentDir,
Expand Down
1 change: 1 addition & 0 deletions src/lib/components/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -1068,6 +1068,7 @@
style="width: 120px;"
bind:value={settings.imageDirectory}
placeholder="img"
title={t('settings.imageDirectoryHint', settings.language)}
/>
</div>

Expand Down
1 change: 1 addition & 0 deletions src/lib/utils/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export const translations: Record<LanguageCode, Translation> = {
language: 'Language',
highlightColor: 'Highlight Color',
imageDirectory: 'Image Directory',
imageDirectoryHint: 'Folder for pasted and dropped images, created next to the document. Use ${filename} for the document\'s own name: ${filename}.assets gives every document its own folder instead of one shared img/.',
scaleMacOSScreenshots: 'Scale macOS Screenshots',
reduceSizeBy50: 'Reduce size by 50%',
toolbar: 'Toolbar',
Expand Down
35 changes: 35 additions & 0 deletions src/lib/utils/imageEmbed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,3 +93,38 @@ export function encodeImageDestination(relPath: string): string {
export function imageEmbed(relPath: string): string {
return `![alt](${encodeImageDestination(relPath)})`;
}

/** The one token `imageDirectory` expands. Spelled as Typora and SoloMD spell it. */
const FILENAME_TOKEN = '${filename}';

/**
* The folder this document's images go in: the `imageDirectory` setting, with
* `${filename}` standing for the document's own name.
*
* Without the token every document in a directory shares one `img/`, which is
* what #714 asked to be rid of: `${filename}.assets` next to `notes/trip.md`
* is `notes/trip.assets`, one image folder per document. The token is spelled
* the way Typora and SoloMD spell it, because a user arriving from either
* types what worked there.
*
* What comes back is a path *component*, not a path. `trip.assets` still has
* to pass Rust's `safe_path_component`, which refuses separators, `.`, `..`
* and absolute paths so the folder cannot leave the document's directory —
* and that guard is why this function special-cases no stem of its own. A file
* named `..md` expands to `.`, and Rust is the one that says no.
*
* The substitution is `split`/`join` rather than `String.replace` because the
* replacement is a filename the user chose: `replace` reads `$&` and `$'` in a
* replacement string as backreferences, so a document named `$&.md` would put
* `${filename}.assets` on disk verbatim.
*/
export function resolveImageDirectory(setting: string, documentPath: string): string {
const template = setting || DEFAULT_IMAGE_DIRECTORY;
if (!template.includes(FILENAME_TOKEN)) return template;
const base = documentPath.split(/[/\\]/).pop() ?? documentPath;
const dot = base.lastIndexOf('.');
// `.gitignore` is all name and no extension: only a dot with something in
// front of it separates the two.
const stem = dot > 0 ? base.slice(0, dot) : base;
return template.split(FILENAME_TOKEN).join(stem);
}
Loading