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
}
@ -45,24 +45,6 @@ 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
@ -101,7 +83,7 @@ export function transformHTML(
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();

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({
@ -163,8 +163,8 @@ export default class PasteReformatter extends Plugin {
// 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,7 +175,7 @@ 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;
}
@ -186,6 +186,7 @@ export default class PasteReformatter extends Plugin {
}
// Apply settings to transform the markdown
console.log(`original markdown: ${originalMarkdown}`);
const markdownResult = transformMarkdown(originalMarkdown, this.settings, contextLevel, escapeMarkdown);
appliedMarkdownTransformations = markdownResult.appliedTransformations;
@ -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,31 +249,41 @@ 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--) {
@ -325,7 +341,7 @@ class PasteReformmatterSettingsTab extends PluginSettingTab {
}
display(): void {
const {containerEl} = this;
const { containerEl } = this;
containerEl.empty();

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,25 +24,27 @@ 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}`);
@ -73,7 +75,7 @@ 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}`);
@ -154,6 +156,8 @@ export function transformMarkdown(
appliedTransformations = (originalMarkdown !== markdown);
}
console.log(`processed: ${markdown}`);
// First handle the special line break markers
let preserveLineBreaks = !settings.stripLineBreaks;
@ -224,6 +228,7 @@ export function transformMarkdown(
appliedTransformations = appliedTransformations || (originalMarkdown !== markdown);
}
console.log(`final: ${markdown}`);
return {
markdown,
appliedTransformations