Fix transform & paste handling issues (#24)

* fix: extra blank lines being added to initial html transform

* fix: ignore selected texted when determining contextual heading level

* fix: ignore events already handled by another plugin

* feat(ci): add CI workflow

* chore: add package-lock.json
This commit is contained in:
Keath Milligan 2026-06-01 14:15:34 -05:00 committed by GitHub
parent a05bdc269e
commit b20b44f218
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 2516 additions and 318 deletions

28
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,28 @@
name: CI
on:
push:
branches:
- main
tags-ignore:
- "*"
pull_request:
branches:
- "**"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18.x"
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npm run build

5
.gitignore vendored
View file

@ -21,11 +21,6 @@ dist/
dist
build/
# Dependency lock files (keep package.json)
package-lock.json
yarn.lock
pnpm-lock.yaml
# Testing
coverage/
.nyc_output/

View file

@ -1,158 +0,0 @@
# Heading Cascade Rules
## Scanario: Cascade Heading Levels is Disabled
When Cascade Heading Levels is not enabled, then the Max Heading Level setting simply caps the maximum heading value that is allowed. For instance, if it is set to H3, then H1 and H2 would simply be changed to H3. Otherwise, heading values are not affected.
### Example
If Max heading Level is set to H3, then the following text when pasted:
```
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
```
Will be transformed into:
```
### Heading 1
### Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
### Heading 1
### Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
```
## Scenario: Cascade Heading Levels is Enabled
When Cascade Heading Levels is enabled, the heading values that are greater (in this case "greater" means H1 is greater than H2, H2 is greater than H3, etc.) than the max heading level setting are changed to the max heading level.
In addition subsequent headings values are demoted ("cascaded") to the heading level below.
### Example
When max heading level is set to H3 and the following text was pasted:
```
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
```
The result would be:
```
### Heading 1
#### Heading 2
##### Heading 3
###### Heading 4
###### Heading 5
###### Heading 6
### Heading 1
#### Heading 2
##### Heading 3
###### Heading 4
###### Heading 5
###### Heading 6
```
(heading levels will never be demoted below H6, e.g. "######")
### Another Example
When Max Heading Level is set to H2. The following should remain unchanged:
```
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
```
# Contextual Cascade Rules
Contextual Cascade is completely independent of Cascade Heading Levels.
## Scenario: Contextual Cascade is Disabled
When Contextual Cascade is disabled, then the normal Max Heading Level and Cascade Heading Levels rules apply as usual.
## Scenario: Contextual Cascade is Enabled
When Contextual Cascade is enabled, then the Max Heading Level and Cascade Heading Levels rules are superceded by the Contextual Cascade rules:
### Example
When text is pasted into an H2 section, for example, then headings are cascaded down from H3. For example, if the following text was pasted into an H2 section:
```
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
```
The result would be:
```
### Heading 1
#### Heading 2
##### Heading 3
###### Heading 4
###### Heading 5
###### Heading 6
### Heading 1
#### Heading 2
##### Heading 3
###### Heading 4
###### Heading 5
###### Heading 6
```
As with regular cascading, the heading levels will never be demoted below H6, e.g. "######".

2330
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,7 @@
export function transformHTML(
html: string,
settings: {
htmlRegexReplacements: Array<{pattern: string, replacement: string}>,
htmlRegexReplacements: Array<{ pattern: string, replacement: string }>,
stripLineBreaks: boolean,
removeEmptyElements: boolean
}
@ -32,11 +32,11 @@ export function transformHTML(
}
}
}
// Create a temporary DOM element to parse the HTML
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Process line breaks if strip line breaks is enabled
if (settings.stripLineBreaks) {
// Find all <br> elements and remove them
@ -45,26 +45,8 @@ export function transformHTML(
br.remove();
});
appliedTransformations = true;
} else {
// If we're not stripping line breaks, convert them to special paragraph tags
// that will be preserved even if empty elements are removed
const brElements = doc.querySelectorAll('br');
brElements.forEach(br => {
// Create a special paragraph with a class that marks it as a line break
const lineBreakP = doc.createElement('p');
lineBreakP.className = 'preserve-line-break';
lineBreakP.setAttribute('data-preserve', 'true');
// Add a non-breaking space as placeholder content
// This ensures the paragraph isn't considered empty when Remove Empty Lines is enabled
// The unicode character will be invisible but ensures the line isn't empty
lineBreakP.textContent = '\u200B'; // Zero-width space
// Replace the <br> with our special paragraph
br.parentNode?.replaceChild(lineBreakP, br);
});
}
// Remove empty elements if enabled
if (settings.removeEmptyElements) {
// Function to check if an element is empty (no text content and no meaningful children)
@ -73,35 +55,35 @@ export function transformHTML(
if (['img', 'hr', 'br', 'input', 'iframe'].includes(element.tagName.toLowerCase())) {
return false;
}
// Skip our special line break paragraphs
if (element.hasAttribute('data-preserve')) {
return false;
}
// Check if it has any text content (include whitespace)
if (element.textContent && element.textContent.length > 0) {
return false;
}
// Check if it has any non-empty children
for (let i = 0; i < element.children.length; i++) {
if (!isElementEmpty(element.children[i])) {
return false;
}
}
return true;
};
// Find and remove empty elements
// We need to use a while loop because the DOM changes as we remove elements
let emptyElementsFound = true;
while (emptyElementsFound) {
emptyElementsFound = false;
// Target common empty elements
const potentialEmptyElements = doc.querySelectorAll('p:not([data-preserve]), div, span, li, ul, ol, table, tr, td, th');
const potentialEmptyElements = doc.querySelectorAll('p, div, span, li, ul, ol, table, tr, td, th');
potentialEmptyElements.forEach(element => {
if (isElementEmpty(element)) {
element.remove();
@ -109,14 +91,14 @@ export function transformHTML(
appliedTransformations = true;
}
});
// If no more empty elements are found, exit the loop
if (!emptyElementsFound) {
break;
}
}
}
// Return the modified HTML and transformation status
const serializer = new XMLSerializer();
return {

View file

@ -52,24 +52,24 @@ export default class PasteReformatter extends Plugin {
// Register discrete command for paste reformatting
this.addCommand({
id: 'reformat-and-paste',
name: 'Reformat and Paste',
callback: async () => {
try {
// Get clipboard data
const dataTransfer = await this.getClipboardData();
if (dataTransfer) {
id: 'reformat-and-paste',
name: 'Reformat and Paste',
callback: async () => {
try {
// Get clipboard data
const dataTransfer = await this.getClipboardData();
if (dataTransfer) {
// Paste it into the active editor
this.doPaste(dataTransfer);
} else {
new Notice("Clipboard does not contain HTML or plain text content.");
new Notice("Clipboard does not contain HTML or plain text content.");
}
} catch (error) {
console.error("Error accessing clipboard:", error);
new Notice("Error accessing clipboard. Try using regular paste instead.");
}
}
});
} catch (error) {
console.error("Error accessing clipboard:", error);
new Notice("Error accessing clipboard. Try using regular paste instead.");
}
}
});
// Register command to paste with all markdown escaped
this.addCommand({
@ -104,10 +104,10 @@ export default class PasteReformatter extends Plugin {
try {
// Get clipboard data using navigator API
const clipboardItems = await navigator.clipboard.read();
// Create a DataTransfer object
const dataTransfer = new DataTransfer();
// Process clipboard items
for (const item of clipboardItems) {
// Check for HTML content
@ -116,7 +116,7 @@ export default class PasteReformatter extends Plugin {
const html = await blob.text();
dataTransfer.setData('text/html', html);
}
// Check for plain text content
if (item.types.includes('text/plain')) {
const blob = await item.getType('text/plain');
@ -124,14 +124,14 @@ export default class PasteReformatter extends Plugin {
dataTransfer.setData('text/plain', text);
}
}
// Process the clipboard data
if (dataTransfer.types.includes('text/html') || dataTransfer.types.includes('text/plain')) {
return dataTransfer;
} else {
new Notice("Clipboard does not contain HTML or plain text content.");
}
} catch (error) {
console.error("Error accessing clipboard:", error);
new Notice("Error accessing clipboard. Try using regular paste instead.");
@ -145,27 +145,27 @@ export default class PasteReformatter extends Plugin {
if (!activeView) {
return false;
}
const editor = activeView.editor;
if (!editor) {
return false;
}
try {
let originalMarkdown = '';
let appliedHTMLTransformations = false;
let appliedMarkdownTransformations = false;
// Check if HTML format is available
if (clipboardData.types.includes('text/html')) {
// Process as HTML
const html = clipboardData.getData('text/html');
// Transform HTML before converting to Markdown
const result = transformHTML(html, this.settings);
console.debug(`Original HTML: ${html}`);
console.debug(`Transformed HTML: ${result.html}`);
// console.log(`Original HTML: ${html}`);
console.log(`Transformed HTML: ${result.html}`);
// Use Obsidian's built-in htmlToMarkdown function
originalMarkdown = htmlToMarkdown(result.html);
@ -175,20 +175,21 @@ export default class PasteReformatter extends Plugin {
originalMarkdown = clipboardData.getData('text/plain');
} else {
// No supported format found
console.debug("No HTML or plain text content found in clipboard");
// console.log("No HTML or plain text content found in clipboard");
return false;
}
// Get the current context for contextual cascade
let contextLevel = 0;
if (this.settings.contextualCascade) {
contextLevel = this.getCurrentHeadingLevel(editor);
}
// Apply settings to transform the markdown
console.log(`original markdown: ${originalMarkdown}`);
const markdownResult = transformMarkdown(originalMarkdown, this.settings, contextLevel, escapeMarkdown);
appliedMarkdownTransformations = markdownResult.appliedTransformations;
// Show notification
if (appliedHTMLTransformations || appliedMarkdownTransformations) {
// Replace the current selection with the converted markdown
@ -233,7 +234,12 @@ export default class PasteReformatter extends Plugin {
}
onPaste(event: ClipboardEvent) {
if (event.defaultPrevented) {
return;
}
if (this.settings.pasteOverride) {
// Check if there's content in the clipboard
const clipboardData = event.clipboardData;
if (!clipboardData) {
@ -243,43 +249,53 @@ export default class PasteReformatter extends Plugin {
// Skip reformatting when the cursor is inside a fenced code block
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (activeView?.editor && this.isCursorInCodeBlock(activeView.editor)) {
console.debug("Paste Reformatter plugin skipping paste: cursor is inside a code block");
console.log("Paste Reformatter plugin skipping paste: cursor is inside a code block");
return;
}
// Process the clipboard data
if (this.doPaste(clipboardData)) {
// Prevent the default paste behavior
console.debug("Default paste behavior overridden by Paste Reformatter plugin");
console.log("Default paste behavior overridden by Paste Reformatter plugin");
event.preventDefault();
} else {
// If the plugin didn't handle the paste, allow the default behavior
console.debug("Paste Reformatter plugin did not handle paste, allowing default behavior");
console.log("Paste Reformatter plugin did not handle paste, allowing default behavior");
}
}
}
/**
* Determines the current heading level at the cursor position
* Determines the current heading level at the cursor position.
* If text is selected, the selection is excluded from the search
* since it will be replaced by the pasted content.
* @param editor The editor instance
* @returns The heading level (1-6) or 0 if not in a heading section
*/
getCurrentHeadingLevel(editor: any): number {
// Get the current cursor position
const cursor = editor.getCursor();
const currentLine = cursor.line;
let currentLine = cursor.line;
// If text is selected, ignore the selected text when looking backward
// since it is going to be replaced
if (editor.somethingSelected()) {
const anchor = editor.getCursor('anchor');
const head = editor.getCursor('head');
currentLine = Math.min(anchor.line, head.line) - 1;
}
// Look backward from the current line to find the nearest heading
for (let line = currentLine; line >= 0; line--) {
const lineText = editor.getLine(line);
const headingMatch = lineText.match(/^(#{1,6})\s/);
if (headingMatch) {
// Return the heading level (number of # characters)
return headingMatch[1].length;
}
}
// No heading found above the cursor
return 0;
}
@ -297,10 +313,10 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
// Find the appropriate table based on type
const containerClass = type === 'html' ? 'regex-replacements-container' : 'regex-replacements-container';
const containers = this.containerEl.querySelectorAll('.regex-replacements-container');
// Get the correct container (HTML is first, Markdown is second)
const targetContainer = type === 'html' ? containers[0] : containers[1];
if (targetContainer) {
const table = targetContainer.querySelector('table');
if (table) {
@ -308,12 +324,12 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
if (tbody) {
const rows = tbody.querySelectorAll('tr');
const lastRow = rows[rows.length - 1];
if (lastRow) {
// Check if the row is already visible
const containerRect = this.containerEl.getBoundingClientRect();
const rowRect = lastRow.getBoundingClientRect();
// If the row is not fully visible, scroll it into view
if (rowRect.bottom > containerRect.bottom || rowRect.top < containerRect.top) {
lastRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
@ -325,10 +341,10 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
}
display(): void {
const {containerEl} = this;
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Override default paste behavior')
.setDesc('Alter the behavior of the default paste action to reformat pasted content.')
@ -348,13 +364,13 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
this.plugin.settings.showPasteNotifications = value;
await this.plugin.saveSettings();
}));
// HTML Transformations
new Setting(containerEl)
.setName('HTML transformations')
.setHeading()
.setDesc('Control how the HTML content is processed before being converted to Markdown.');
new Setting(containerEl)
.setName('Remove empty elements')
.setDesc('Remove empty elements when reformatting pasted content')
@ -364,7 +380,7 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
this.plugin.settings.removeEmptyElements = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Strip hard line breaks')
.setDesc('Remove line breaks (br tags) when reformatting pasted content')
@ -374,51 +390,51 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
this.plugin.settings.stripLineBreaks = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('HTML regex replacements')
.setDesc('Apply regular expression replacements to the HTML content before converting to Markdown. You can use $1, $2, etc. to reference capture groups.');
// Create a container for the regex replacement rows
const regexContainer = containerEl.createDiv();
regexContainer.addClass('regex-replacements-container');
// Create a table for the regex replacements
const table = regexContainer.createEl('table');
table.addClass('regex-table');
// Create the header row
const thead = table.createEl('thead');
const headerRow = thead.createEl('tr');
// Pattern header
const patternHeader = headerRow.createEl('th');
patternHeader.setText('Pattern');
patternHeader.addClass('regex-th');
patternHeader.addClass('regex-th-pattern');
// Replacement header
const replacementHeader = headerRow.createEl('th');
replacementHeader.setText('Replacement');
replacementHeader.addClass('regex-th');
replacementHeader.addClass('regex-th-replacement');
// Actions header
const actionsHeader = headerRow.createEl('th');
actionsHeader.addClass('regex-th');
actionsHeader.addClass('regex-th-actions');
// Create the table body
const tbody = table.createEl('tbody');
// Add a row for each replacement
this.plugin.settings.htmlRegexReplacements.forEach((replacement, index) => {
const row = tbody.createEl('tr');
// Pattern cell
const patternCell = row.createEl('td');
patternCell.addClass('regex-td');
// Pattern input
const patternInput = document.createElement('input');
patternInput.type = 'text';
@ -430,11 +446,11 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
await this.plugin.saveSettings();
});
patternCell.appendChild(patternInput);
// Replacement cell
const replacementCell = row.createEl('td');
replacementCell.addClass('regex-td');
// Replacement input
const replacementInput = document.createElement('input');
replacementInput.type = 'text';
@ -446,12 +462,12 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
await this.plugin.saveSettings();
});
replacementCell.appendChild(replacementInput);
// Actions cell
const actionsCell = row.createEl('td');
actionsCell.addClass('regex-td');
actionsCell.addClass('regex-td-actions');
// Remove icon
const removeIcon = document.createElement('span');
removeIcon.className = 'regex-remove-icon';
@ -474,7 +490,7 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
});
actionsCell.appendChild(removeIcon);
});
// Add a message if no replacements are defined
if (this.plugin.settings.htmlRegexReplacements.length === 0) {
const emptyRow = tbody.createEl('tr');
@ -483,7 +499,7 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
emptyCell.addClass('regex-empty-message');
emptyCell.setText('No replacements defined. Click the + icon below to add one.');
}
// Add plus-circle icon for adding new HTML replacements
const htmlAddIcon = regexContainer.createEl('div');
htmlAddIcon.className = 'regex-add-icon';
@ -497,7 +513,7 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
const hasEmptyRow = this.plugin.settings.htmlRegexReplacements.some(
replacement => replacement.pattern === '' && replacement.replacement === ''
);
// Only add a new row if there isn't already an empty one
if (!hasEmptyRow) {
// Add a new empty replacement
@ -523,12 +539,12 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
htmlAddIcon.click();
}
});
new Setting(containerEl)
.setName('Markdown transformations')
.setDesc('Control how the Markdown content is adjusted after HTML conversion or when pasted as plain text.')
.setHeading();
new Setting(containerEl)
.setName('Max heading level')
.setDesc('The maximum heading level to allow when reformatting pasted content (H1 is treated as disabled)')
@ -545,11 +561,11 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.maxHeadingLevel = parseInt(value);
await this.plugin.saveSettings();
// Always refresh the display to update the cascade heading levels toggle visibility
this.display();
}));
// Only show cascade heading levels setting if max heading level is not H1 (disabled)
if (this.plugin.settings.maxHeadingLevel > 1) {
new Setting(containerEl)
@ -562,7 +578,7 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
await this.plugin.saveSettings();
}));
}
new Setting(containerEl)
.setName('Contextual cascade')
.setDesc('Cascade headings based on the current context (e.g., if cursor is in an H2 section, headings will start from H3)')
@ -572,7 +588,7 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
this.plugin.settings.contextualCascade = value;
await this.plugin.saveSettings();
}));
const singleSpacedSetting = new Setting(containerEl)
.setName('Convert to single-spaced')
.setDesc('Collapse multiple consecutive blank lines into a single blank line')
@ -583,12 +599,12 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
this.plugin.settings.convertToSingleSpaced = value;
await this.plugin.saveSettings();
}));
// Add visual indication when disabled
if (this.plugin.settings.removeEmptyLines) {
singleSpacedSetting.settingEl.addClass('paste-reformatter-disabled-setting');
}
new Setting(containerEl)
.setName('Remove empty lines')
.setDesc('Remove blank lines in the Markdown output')
@ -597,55 +613,55 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.removeEmptyLines = value;
await this.plugin.saveSettings();
// Refresh the display to update the disabled state of "Convert to single-spaced"
this.display();
}));
new Setting(containerEl)
.setName('Markdown regex replacements')
.setDesc('Apply regular expression replacements to the Markdown content after HTML conversion. You can use $1, $2, etc. to reference capture groups.');
// Create a container for the regex replacement rows
const markdownRegexContainer = containerEl.createDiv();
markdownRegexContainer.addClass('regex-replacements-container');
// Create a table for the regex replacements
const markdownRegexTable = markdownRegexContainer.createEl('table');
markdownRegexTable.addClass('regex-table');
// Create the header row
const markdownRegexThead = markdownRegexTable.createEl('thead');
const markdownRegexHeaderRow = markdownRegexThead.createEl('tr');
// Pattern header
const markdownRegexPatternHeader = markdownRegexHeaderRow.createEl('th');
markdownRegexPatternHeader.setText('Pattern');
markdownRegexPatternHeader.addClass('regex-th');
markdownRegexPatternHeader.addClass('regex-th-pattern');
// Replacement header
const markdownRegexReplacementHeader = markdownRegexHeaderRow.createEl('th');
markdownRegexReplacementHeader.setText('Replacement');
markdownRegexReplacementHeader.addClass('regex-th');
markdownRegexReplacementHeader.addClass('regex-th-replacement');
// Actions header
const markdownRegexActionsHeader = markdownRegexHeaderRow.createEl('th');
markdownRegexActionsHeader.addClass('regex-th');
markdownRegexActionsHeader.addClass('regex-th-actions');
// Create the table body
const markdownRegexTbody = markdownRegexTable.createEl('tbody');
// Add a row for each replacement
this.plugin.settings.markdownRegexReplacements.forEach((replacement, index) => {
const row = markdownRegexTbody.createEl('tr');
// Pattern cell
const patternCell = row.createEl('td');
patternCell.addClass('regex-td');
// Pattern input
const patternInput = document.createElement('input');
patternInput.type = 'text';
@ -657,11 +673,11 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
await this.plugin.saveSettings();
});
patternCell.appendChild(patternInput);
// Replacement cell
const replacementCell = row.createEl('td');
replacementCell.addClass('regex-td');
// Replacement input
const replacementInput = document.createElement('input');
replacementInput.type = 'text';
@ -673,12 +689,12 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
await this.plugin.saveSettings();
});
replacementCell.appendChild(replacementInput);
// Actions cell
const actionsCell = row.createEl('td');
actionsCell.addClass('regex-td');
actionsCell.addClass('regex-td-actions');
// Remove icon
const removeIcon = document.createElement('span');
removeIcon.className = 'regex-remove-icon';
@ -701,7 +717,7 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
});
actionsCell.appendChild(removeIcon);
});
// Add a message if no replacements are defined
if (this.plugin.settings.markdownRegexReplacements.length === 0) {
const emptyRow = markdownRegexTbody.createEl('tr');
@ -710,7 +726,7 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
emptyCell.addClass('regex-empty-message');
emptyCell.setText('No replacements defined. Click the + icon below to add one.');
}
// Add plus-circle icon for adding new Markdown replacements
const markdownAddIcon = markdownRegexContainer.createEl('div');
markdownAddIcon.className = 'regex-add-icon';
@ -724,7 +740,7 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
const hasEmptyRow = this.plugin.settings.markdownRegexReplacements.some(
replacement => replacement.pattern === '' && replacement.replacement === ''
);
// Only add a new row if there isn't already an empty one
if (!hasEmptyRow) {
// Add a new empty replacement

View file

@ -11,7 +11,7 @@
export function transformMarkdown(
markdown: string,
settings: {
markdownRegexReplacements: Array<{pattern: string, replacement: string}>,
markdownRegexReplacements: Array<{ pattern: string, replacement: string }>,
contextualCascade: boolean,
maxHeadingLevel: number,
cascadeHeadingLevels: boolean,
@ -24,36 +24,38 @@ export function transformMarkdown(
): { markdown: string, appliedTransformations: boolean } {
let appliedTransformations = false;
console.log(`original: ${markdown}`);
// Apply regex replacements if defined
if (settings.markdownRegexReplacements && settings.markdownRegexReplacements.length > 0) {
for (const regex_replacement of settings.markdownRegexReplacements) {
try {
const regex = new RegExp(regex_replacement.pattern, 'g');
const replacement = regex_replacement.replacement
.replace(/\\r\\n/g, '\r\n')
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.replace(/\\'/g, "'")
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\');
.replace(/\\r\\n/g, '\r\n')
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r')
.replace(/\\t/g, '\t')
.replace(/\\'/g, "'")
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\');
const originalMarkdown = markdown;
// console.log(`applying ${JSON.stringify(regex_replacement.pattern)} replacement ${JSON.stringify(replacement)}`); console.log(JSON.stringify(markdown));
markdown = markdown.replace(regex, replacement);
if (originalMarkdown !== markdown) {
appliedTransformations = true;
// console.log("replaced text")
console.log(`regex replacements: ${markdown}`);
}
} catch (error) {
console.error(`Error applying markdown regex replacement: ${error}`);
}
}
}
if (!escapeMarkdown) {
// Find all heading lines
const headingRegex = /^(#{1,6})\s/gm;
// Process headings based on settings
if (settings.contextualCascade && contextLevel > 0) {
let delta = -1;
@ -73,11 +75,11 @@ export function transformMarkdown(
newLevel = Math.min(contextLevel + 1, 6);
delta = newLevel - currentLevel;
cascading = true;
console.log(`contextual cascade initiated: delta: ${delta}`);
console.log(`*** contextual cascade initiated: delta: ${delta}`);
} // else nothing to do
console.log(`result: current level: ${currentLevel}, new level: ${newLevel}`);
appliedTransformations = appliedTransformations || (newLevel !== currentLevel);
// Return the new heading with the adjusted level
return '#'.repeat(newLevel) + ' ';
@ -89,7 +91,7 @@ export function transformMarkdown(
markdown = markdown.replace(headingRegex, (match, hashes) => {
const currentLevel = hashes.length;
let newLevel = currentLevel;
if (settings.cascadeHeadingLevels) {
// If cascading is enabled, start cascading subsequent headings down if needed
if (cascading) {
@ -106,7 +108,7 @@ export function transformMarkdown(
// Cascading not enabled, just cap heading levels at max
newLevel = Math.max(currentLevel, settings.maxHeadingLevel)
}
console.log(`result: current level: ${currentLevel}, new level: ${newLevel}`);
appliedTransformations = appliedTransformations || (newLevel !== currentLevel);
@ -119,7 +121,7 @@ export function transformMarkdown(
// If escaping markdown, we don't want to change headings
// Just escape the markdown content
const originalMarkdown = markdown;
// Escape all Markdown syntax that Obsidian recognizes
// Headings, bold/italic, lists, links, images, code blocks, blockquotes, etc.
markdown = markdown
@ -150,80 +152,83 @@ export function transformMarkdown(
// Otherwise it's an HTML tag that needs escaping
return htmlTag ? '`' + htmlTag + '`' : match;
});
appliedTransformations = (originalMarkdown !== markdown);
}
console.log(`processed: ${markdown}`);
// First handle the special line break markers
let preserveLineBreaks = !settings.stripLineBreaks;
// Convert multiple consecutive blank lines to single blank line if enabled
// Skip this if removeEmptyLines is enabled (optimization - lines will be removed anyway)
if (settings.convertToSingleSpaced && !settings.removeEmptyLines) {
// Normalize line endings to ensure consistent processing
markdown = markdown.replace(/\r\n/g, '\n');
// Replace 2 or more consecutive newlines with exactly 2 newlines (1 blank line)
const originalMarkdown = markdown;
markdown = markdown.replace(/\n{3,}/g, '\n\n');
if (originalMarkdown !== markdown) {
appliedTransformations = true;
}
}
// Process empty lines with a sliding window approach
if (settings.removeEmptyLines) {
// First, normalize line endings to ensure consistent processing
markdown = markdown.replace(/\r\n/g, '\n');
// Split the content into lines
const lines = markdown.split('\n');
const filteredLines: string[] = [];
// Sliding window processing with peek capability
for (let i = 0; i < lines.length; i++) {
const currentLine = lines[i];
const nextLine = i + 1 < lines.length ? lines[i + 1] : null;
const isCurrentLineEmpty = currentLine.trim() === '';
// Rule 1: Preserve line breaks - check for special markers
if (preserveLineBreaks) {
const hasPreserveMarker =
const hasPreserveMarker =
/<p class="preserve-line-break"[^>]*>.*?<\/p>/.test(currentLine) ||
/<p data-preserve="true"[^>]*>.*?<\/p>/.test(currentLine);
if (hasPreserveMarker) {
// Insert an empty line instead of the marker
filteredLines.push('');
continue;
}
}
// Rule 2: Keep empty line if next line is a horizontal rule (3+ dashes)
if (isCurrentLineEmpty && nextLine !== null && /^\s*-{3,}\s*$/.test(nextLine)) {
filteredLines.push(currentLine);
continue;
}
// Rule 3: Keep empty line if next line is the beginning of a table
if (isCurrentLineEmpty && nextLine !== null && /^\s*\|.*\|/.test(nextLine)) {
filteredLines.push(currentLine);
continue;
}
// Default: Remove empty lines unless they meet the above criteria
if (!isCurrentLineEmpty) {
filteredLines.push(currentLine);
}
}
// Join the filtered lines back together
const originalMarkdown = markdown;
markdown = filteredLines.join('\n');
appliedTransformations = appliedTransformations || (originalMarkdown !== markdown);
}
console.log(`final: ${markdown}`);
return {
markdown,
appliedTransformations