Skip to content

Commit bc732de

Browse files
committed
v1.0.3: New features and bug fixes
Features: - Add 'Paste as block link' command - creates wikilink to copied block - Add 'Show block IDs' setting to toggle visibility of markers - Expand selection now includes all children when selecting parent→child Bug fixes: - Fix: Only allow mirroring root-level items (prevents infinite loops) - Fix: Block IDs no longer synced to mirror children - Fix: Remove block IDs from mirror content during creation and sync Docs: - Update README with new commands and settings Translations: - Add Russian translations for new features
1 parent 6b79d13 commit bc732de

File tree

10 files changed

+214
-10
lines changed

10 files changed

+214
-10
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ Create synchronized copies of outline blocks that stay in sync with the original
105105
| Command | Description |
106106
|--------------------------------|----------------------------------------------------------|
107107
| Paste as linked copy (mirror) | Paste copied outline as a mirror that syncs with original |
108+
| Paste as block link | Paste a wikilink to the copied block (no sync, just navigation) |
108109
| Go to original | Navigate from mirror to its source block |
109110
| Break mirror link | Convert mirror to regular text (stops syncing) |
110111

@@ -114,6 +115,7 @@ Create synchronized copies of outline blocks that stay in sync with the original
114115
- **Visual indicator:** Mirrors show a dashed bullet outline
115116
- **Cascade delete:** When original is deleted, mirrors are removed
116117
- **Auto-repair:** Block IDs are automatically repaired if corrupted
118+
- **Root-level only:** Only top-level list items can be mirrored (nested items use block links)
117119

118120
**Setting:** Linked Copies (Mirrors) — On by default
119121

@@ -190,6 +192,7 @@ Full support for multiple languages. Currently supports:
190192
| Setting | Default |
191193
|----------------------------------------|:-------:|
192194
| Enable Linked Copies (Mirrors) | On |
195+
| Show block IDs and mirror markers | Off |
193196

194197
### Debug
195198

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"id": "obsidian-pro-outliner",
33
"name": "Pro Outliner",
4-
"version": "1.0.2",
4+
"version": "1.0.3",
55
"minAppVersion": "1.8.7",
66
"description": "Work with your lists like in Workflowy, RoamResearch, or Tana, with powerful zoom functionality. Combines the best of Outliner and Zoom plugins.",
77
"author": "Ruben Khachaturov",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "obsidian-pro-outliner",
3-
"version": "1.0.2",
3+
"version": "1.0.3",
44
"description": "Work with your lists like in Workflowy, RoamResearch, or Tana, with zoom functionality.",
55
"main": "main.js",
66
"scripts": {

src/features/LinkedCopiesFeature.ts

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,26 @@ export class LinkedCopiesFeature implements Feature {
5858
}
5959
}
6060

61+
/**
62+
* Update CSS class based on showBlockIds setting
63+
*/
64+
private updateShowBlockIdsClass() {
65+
if (this.settings.showBlockIds) {
66+
document.body.classList.add("outliner-show-block-ids");
67+
} else {
68+
document.body.classList.remove("outliner-show-block-ids");
69+
}
70+
}
71+
6172
async load() {
73+
// Apply initial showBlockIds class
74+
this.updateShowBlockIdsClass();
75+
76+
// Listen for settings changes
77+
this.settings.onChange("showBlockIds", () => {
78+
this.updateShowBlockIdsClass();
79+
});
80+
6281
// Register command for pasting as linked copy (no default hotkey - user can set their own)
6382
this.plugin.addCommand({
6483
id: "paste-as-linked-copy",
@@ -69,6 +88,16 @@ export class LinkedCopiesFeature implements Feature {
6988
},
7089
});
7190

91+
// Register command for pasting as block link (just a wikilink, no content sync)
92+
this.plugin.addCommand({
93+
id: "paste-as-block-link",
94+
name: t("cmd.paste-as-block-link"),
95+
icon: "link",
96+
editorCallback: (editor: Editor, view: MarkdownView) => {
97+
this.pasteAsBlockLink(editor, view);
98+
},
99+
});
100+
72101
// Register command to go to original from mirror
73102
this.plugin.addCommand({
74103
id: "go-to-original",
@@ -123,6 +152,8 @@ export class LinkedCopiesFeature implements Feature {
123152
clearTimeout(this.syncDebounceTimer);
124153
}
125154
this.mirrorCache.clear();
155+
// Clean up CSS class
156+
document.body.classList.remove("outliner-show-block-ids");
126157
}
127158

128159
/**
@@ -434,6 +465,14 @@ export class LinkedCopiesFeature implements Feature {
434465
return;
435466
}
436467

468+
// PROTECTION: Only allow mirroring root-level items (indent level 0)
469+
const sourceIndent = this.getIndentLevel(this.lastCopySource.content);
470+
if (sourceIndent > 0) {
471+
this.log("Source is not at root level - cannot create mirror");
472+
new Notice(t("notice.only-root-level-mirror"));
473+
return;
474+
}
475+
437476
// Find the source file and update it with block ID if needed
438477
const sourceFile = this.plugin.app.vault.getAbstractFileByPath(
439478
this.lastCopySource.filePath,
@@ -584,6 +623,99 @@ export class LinkedCopiesFeature implements Feature {
584623
new Notice(t("notice.pasted-as-mirror"));
585624
}
586625

626+
/**
627+
* Paste clipboard content as a block link (wikilink to the source block)
628+
* Creates: [[SourceNote#^outliner-xxx|Display text]]
629+
*/
630+
private async pasteAsBlockLink(editor: Editor, _view: MarkdownView) {
631+
this.log("Paste as block link triggered");
632+
633+
if (!this.settings.linkedCopies) {
634+
this.log("Feature disabled");
635+
new Notice(t("notice.feature-disabled"));
636+
return;
637+
}
638+
639+
// Check if we have a recent copy source (within 5 minutes)
640+
const maxAge = 5 * 60 * 1000;
641+
if (
642+
!this.lastCopySource ||
643+
Date.now() - this.lastCopySource.timestamp > maxAge
644+
) {
645+
this.log("No recent copy source found");
646+
new Notice(t("notice.no-recent-copy"));
647+
return;
648+
}
649+
650+
// Find the source file
651+
const sourceFile = this.plugin.app.vault.getAbstractFileByPath(
652+
this.lastCopySource.filePath,
653+
);
654+
655+
if (!sourceFile || !(sourceFile instanceof TFile)) {
656+
this.log("Source file not found");
657+
new Notice(t("notice.source-file-not-found"));
658+
return;
659+
}
660+
661+
// Read the source file to get current content
662+
const sourceContent = await this.plugin.app.vault.read(sourceFile);
663+
const sourceLines = sourceContent.split("\n");
664+
const sourceLine = sourceLines[this.lastCopySource.line];
665+
666+
if (!sourceLine) {
667+
this.log("Source line is empty/undefined");
668+
new Notice(t("notice.source-line-not-found"));
669+
return;
670+
}
671+
672+
// Check if source line still matches what we copied
673+
const sourceLineClean = this.store.removeBlockId(sourceLine);
674+
const copiedLineClean = this.store.removeBlockId(
675+
this.lastCopySource.content,
676+
);
677+
678+
if (sourceLineClean !== copiedLineClean) {
679+
this.log("Source content has changed");
680+
new Notice(t("notice.source-changed"));
681+
return;
682+
}
683+
684+
// Get or create block ID for the source
685+
let sourceId = this.store.parseBlockId(sourceLine);
686+
687+
// Check if block ID needs repair (missing space before ^)
688+
if (sourceId && this.store.hasBlockIdWithoutSpace(sourceLine)) {
689+
this.log("Block ID needs repair - missing space before ^");
690+
const repairedLine = this.store.repairBlockId(sourceLine);
691+
sourceLines[this.lastCopySource.line] = repairedLine;
692+
await this.plugin.app.vault.modify(sourceFile, sourceLines.join("\n"));
693+
} else if (!sourceId) {
694+
// Generate new ID and update the source file
695+
sourceId = this.store.generateId();
696+
this.log("Generated new ID:", sourceId);
697+
const updatedSourceLine = this.store.addBlockId(sourceLine, sourceId);
698+
sourceLines[this.lastCopySource.line] = updatedSourceLine;
699+
await this.plugin.app.vault.modify(sourceFile, sourceLines.join("\n"));
700+
}
701+
702+
// Extract the display text (content without prefix/markers)
703+
const displayText = this.store.extractListContent(sourceLine);
704+
705+
// Build the wikilink: [[SourceNote#^block-id|Display text]]
706+
const sourceNoteName = sourceFile.basename;
707+
const wikilink = `[[${sourceNoteName}#^${sourceId}|${displayText}]]`;
708+
709+
this.log("Created wikilink:", wikilink);
710+
711+
// Insert at cursor position
712+
const cursor = editor.getCursor();
713+
editor.replaceRange(wikilink, cursor);
714+
715+
this.log("Block link pasted!");
716+
new Notice(t("notice.pasted-as-block-link"));
717+
}
718+
587719
/**
588720
* Check if a line is a list item
589721
*/
@@ -763,9 +895,12 @@ export class LinkedCopiesFeature implements Feature {
763895
const mirrorBaseIndent = mirrorIndentMatch ? mirrorIndentMatch[1] : "";
764896

765897
// Adjust original children to match mirror's indentation level
898+
// Also remove any block IDs from children (they should only exist in the original)
766899
const adjustedChildren = original.children.map((child) => {
900+
// Remove block ID if present
901+
const childWithoutBlockId = this.store.removeBlockId(child);
767902
// Each child should have mirror's base indent added
768-
return mirrorBaseIndent + child;
903+
return mirrorBaseIndent + childWithoutBlockId;
769904
});
770905

771906
this.log(" Current children:", currentChildren);

src/features/SettingsTab.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,16 @@ class ObsidianProOutlinerPluginSettingTab extends PluginSettingTab {
178178
});
179179
});
180180

181+
new Setting(containerEl)
182+
.setName(t("settings.show-block-ids"))
183+
.setDesc(t("settings.show-block-ids-desc"))
184+
.addToggle((toggle) => {
185+
toggle.setValue(this.settings.showBlockIds).onChange(async (value) => {
186+
this.settings.showBlockIds = value;
187+
await this.settings.save();
188+
});
189+
});
190+
181191
containerEl.createEl("h2", { text: t("settings.debug-title") });
182192

183193
new Setting(containerEl)

src/operations/ExpandSelectionToFullItems.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Operation } from "./Operation";
22

3-
import { Root, cmpPos, maxPos, minPos } from "../root";
3+
import { List, Root, cmpPos, maxPos, minPos } from "../root";
44

55
export class ExpandSelectionToFullItems implements Operation {
66
private stopPropagation = false;
@@ -16,6 +16,20 @@ export class ExpandSelectionToFullItems implements Operation {
1616
return this.updated;
1717
}
1818

19+
/**
20+
* Check if listA is an ancestor of listB
21+
*/
22+
private isAncestor(listA: List, listB: List): boolean {
23+
let current = listB.getParent();
24+
while (current) {
25+
if (current === listA) {
26+
return true;
27+
}
28+
current = current.getParent();
29+
}
30+
return false;
31+
}
32+
1933
perform() {
2034
const { root } = this;
2135

@@ -53,7 +67,16 @@ export class ExpandSelectionToFullItems implements Operation {
5367

5468
// Calculate the expanded range
5569
const expandedStart = listAtFrom.getFirstLineContentStartAfterCheckbox();
56-
const expandedEnd = listAtTo.getContentEndIncludingChildren();
70+
71+
// If listAtFrom is an ancestor of listAtTo, expand to include ALL children of listAtFrom
72+
// This handles the case: selecting from parent down to any child should select all children
73+
let expandedEnd;
74+
if (this.isAncestor(listAtFrom, listAtTo)) {
75+
// Expand to include all descendants of the parent
76+
expandedEnd = listAtFrom.getContentEndIncludingChildren();
77+
} else {
78+
expandedEnd = listAtTo.getContentEndIncludingChildren();
79+
}
5780

5881
// Check if selection is already expanded (to avoid infinite loops)
5982
if (

src/services/LinkedCopiesStore.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,9 @@ export class LinkedCopiesStore {
293293
return mirrorLine;
294294
}
295295

296-
return [mirrorLine, ...children].join("\n");
296+
// Remove any block IDs from children (they should only exist in the original)
297+
const cleanedChildren = children.map((child) => this.removeBlockId(child));
298+
299+
return [mirrorLine, ...cleanedChildren].join("\n");
297300
}
298301
}

src/services/Settings.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ interface SettingsObject {
2424
zoomOnClickMobile: boolean;
2525
// Linked copies settings
2626
linkedCopies: boolean;
27+
showBlockIds: boolean;
2728
}
2829

2930
const DEFAULT_SETTINGS: SettingsObject = {
@@ -44,6 +45,7 @@ const DEFAULT_SETTINGS: SettingsObject = {
4445
zoomOnClickMobile: false,
4546
// Linked copies settings
4647
linkedCopies: true,
48+
showBlockIds: false,
4749
};
4850

4951
export interface Storage {
@@ -192,6 +194,14 @@ export class Settings {
192194
this.set("linkedCopies", value);
193195
}
194196

197+
get showBlockIds() {
198+
return this.values.showBlockIds;
199+
}
200+
201+
set showBlockIds(value: boolean) {
202+
this.set("showBlockIds", value);
203+
}
204+
195205
// Simple callback for any change
196206
onChange(cb: Callback): void;
197207
// Key-specific callback

src/services/i18n.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const translations = {
1111
// COMMANDS
1212
// ===========================================
1313
"cmd.paste-as-linked-copy": "Paste as linked copy (mirror)",
14+
"cmd.paste-as-block-link": "Paste as block link",
1415
"cmd.go-to-original": "Go to original (from mirror)",
1516
"cmd.break-mirror-link": "Break mirror link (convert to regular text)",
1617
"cmd.zoom-in": "Zoom in",
@@ -33,10 +34,13 @@ const translations = {
3334
"No recent copy found. Copy a list item first, then use this command to paste as mirror.",
3435
"notice.cannot-mirror-mirror":
3536
"Cannot create mirror of a mirror. Copy the original instead.",
37+
"notice.only-root-level-mirror":
38+
"Only root-level items can be mirrored. Copy an item at the top level of the list.",
3639
"notice.source-file-not-found": "Source file no longer exists.",
3740
"notice.source-line-not-found": "Source line no longer exists.",
3841
"notice.source-changed": "Source content has changed. Please copy again.",
3942
"notice.pasted-as-mirror": "Pasted as linked copy (mirror)",
43+
"notice.pasted-as-block-link": "Pasted as block link",
4044
"notice.not-on-mirror": "Cursor is not on a mirror block",
4145
"notice.not-on-mirror-line": "Cursor is not on a mirror line",
4246
"notice.original-not-found": "Original block not found",
@@ -116,13 +120,17 @@ const translations = {
116120
"settings.vertical-lines-action-fold": "Toggle Folding",
117121
"settings.linked-copies-full-desc":
118122
"Create synchronized copies of list items. Copy normally, then use the 'Paste as linked copy' command (set your own hotkey in Settings → Hotkeys). Changes to the original automatically sync to mirrors.",
123+
"settings.show-block-ids": "Show block IDs and mirror markers",
124+
"settings.show-block-ids-desc":
125+
"Show block IDs (^outliner-xxx) and mirror markers in the editor. Useful for manual cleanup.",
119126
},
120127

121128
ru: {
122129
// ===========================================
123130
// COMMANDS
124131
// ===========================================
125132
"cmd.paste-as-linked-copy": "Вставить как связанную копию (зеркало)",
133+
"cmd.paste-as-block-link": "Вставить как ссылку на блок",
126134
"cmd.go-to-original": "Перейти к оригиналу",
127135
"cmd.break-mirror-link": "Разорвать связь (преобразовать в обычный текст)",
128136
"cmd.zoom-in": "Увеличить (зум)",
@@ -145,10 +153,13 @@ const translations = {
145153
"Нет недавней копии. Сначала скопируйте элемент списка, затем используйте эту команду.",
146154
"notice.cannot-mirror-mirror":
147155
"Нельзя создать зеркало от зеркала. Скопируйте оригинал.",
156+
"notice.only-root-level-mirror":
157+
"Можно создать зеркало только для элементов верхнего уровня. Скопируйте элемент на нулевом уровне списка.",
148158
"notice.source-file-not-found": "Исходный файл больше не существует.",
149159
"notice.source-line-not-found": "Исходная строка больше не существует.",
150160
"notice.source-changed": "Исходный контент изменился. Скопируйте снова.",
151161
"notice.pasted-as-mirror": "Вставлено как связанная копия (зеркало)",
162+
"notice.pasted-as-block-link": "Вставлено как ссылка на блок",
152163
"notice.not-on-mirror": "Курсор не на зеркальном блоке",
153164
"notice.not-on-mirror-line": "Курсор не на строке зеркала",
154165
"notice.original-not-found": "Оригинальный блок не найден",
@@ -227,6 +238,9 @@ const translations = {
227238
"settings.vertical-lines-action-fold": "Свернуть/развернуть",
228239
"settings.linked-copies-full-desc":
229240
"Создавайте синхронизированные копии элементов списка. Скопируйте обычным способом, затем используйте команду 'Вставить как связанную копию' (назначьте горячую клавишу в Настройки → Горячие клавиши). Изменения в оригинале автоматически синхронизируются с зеркалами.",
241+
"settings.show-block-ids": "Показывать ID блоков и маркеры зеркал",
242+
"settings.show-block-ids-desc":
243+
"Показывать ID блоков (^outliner-xxx) и маркеры зеркал в редакторе. Полезно для ручной очистки.",
230244
},
231245
} as const;
232246

0 commit comments

Comments
 (0)