mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 17:37:29 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dd0985ea9 | ||
|
|
18bb6bb702 | ||
|
|
fcf48a5b7a | ||
|
|
c7906f4069 | ||
|
|
e6e1ed1ed6 |
@@ -11,6 +11,7 @@
|
|||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-show",
|
||||||
"core:window:allow-is-maximized",
|
"core:window:allow-is-maximized",
|
||||||
"core:window:allow-set-decorations",
|
"core:window:allow-set-decorations",
|
||||||
"core:window:allow-is-fullscreen",
|
"core:window:allow-is-fullscreen",
|
||||||
|
|||||||
@@ -596,6 +596,20 @@ pub fn save_note(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn duplicate_note(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
path: String,
|
||||||
|
) -> Result<crate::types::NoteEntry, String> {
|
||||||
|
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||||
|
let vault = config.active_vault.as_ref().ok_or("No active vault")?.clone();
|
||||||
|
drop(config);
|
||||||
|
|
||||||
|
let entry = operations::duplicate_note(&path, &vault)?;
|
||||||
|
index_note_bg(&state, &entry.path);
|
||||||
|
Ok(entry)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn create_note(
|
pub fn create_note(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
|
|||||||
+11
-1
@@ -150,6 +150,7 @@ pub fn run() {
|
|||||||
commands::read_note,
|
commands::read_note,
|
||||||
commands::save_note,
|
commands::save_note,
|
||||||
commands::create_note,
|
commands::create_note,
|
||||||
|
commands::duplicate_note,
|
||||||
commands::create_daily_note,
|
commands::create_daily_note,
|
||||||
commands::rename_note,
|
commands::rename_note,
|
||||||
commands::delete_note,
|
commands::delete_note,
|
||||||
@@ -312,7 +313,16 @@ pub fn run() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||||
builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
|
let window_state_builder = tauri_plugin_window_state::Builder::default();
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let window_state_builder = window_state_builder.with_state_flags(
|
||||||
|
tauri_plugin_window_state::StateFlags::SIZE
|
||||||
|
| tauri_plugin_window_state::StateFlags::POSITION
|
||||||
|
| tauri_plugin_window_state::StateFlags::MAXIMIZED
|
||||||
|
| tauri_plugin_window_state::StateFlags::DECORATIONS
|
||||||
|
| tauri_plugin_window_state::StateFlags::FULLSCREEN,
|
||||||
|
);
|
||||||
|
builder = builder.plugin(window_state_builder.build());
|
||||||
|
|
||||||
builder = builder.on_window_event(move |window, event| {
|
builder = builder.on_window_event(move |window, event| {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
|||||||
@@ -476,6 +476,75 @@ pub fn create_note(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn duplicate_note(path: &str, vault_path: &str) -> Result<NoteEntry, String> {
|
||||||
|
let src = Path::new(path);
|
||||||
|
if !src.is_file() {
|
||||||
|
return Err("Note does not exist".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent = src
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| "Note has no parent directory".to_string())?;
|
||||||
|
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
|
||||||
|
let filename = src
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
let (mut meta, body) = frontmatter::parse_note(&raw, &filename);
|
||||||
|
let source_title = meta.title.clone();
|
||||||
|
let base_title = format!("{} copy", source_title.trim_end());
|
||||||
|
let mut copy_title = base_title.clone();
|
||||||
|
let mut copy_number = 2;
|
||||||
|
let mut copy_path = parent.join(format!("{}.md", sanitize_filename(©_title)));
|
||||||
|
|
||||||
|
while copy_path.exists() {
|
||||||
|
copy_title = format!("{} {}", base_title, copy_number);
|
||||||
|
copy_number += 1;
|
||||||
|
copy_path = parent.join(format!("{}.md", sanitize_filename(©_title)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
meta.id = Uuid::new_v4().to_string();
|
||||||
|
meta.title = copy_title;
|
||||||
|
meta.pinned = false;
|
||||||
|
meta.created = now;
|
||||||
|
meta.modified = now;
|
||||||
|
|
||||||
|
let body = retitle_leading_heading(&body, &source_title, &meta.title);
|
||||||
|
let copy_raw = frontmatter::merge_frontmatter(&raw, &meta, &body);
|
||||||
|
fs::write(©_path, copy_raw).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
read_note_entry(©_path, Path::new(vault_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retitle_leading_heading(body: &str, old_title: &str, new_title: &str) -> String {
|
||||||
|
let trimmed = body.trim_start();
|
||||||
|
let leading_len = body.len() - trimmed.len();
|
||||||
|
let first_line_end = trimmed.find('\n').unwrap_or(trimmed.len());
|
||||||
|
let first_line = &trimmed[..first_line_end];
|
||||||
|
let heading_len = first_line.chars().take_while(|&c| c == '#').count();
|
||||||
|
|
||||||
|
if !(1..=6).contains(&heading_len) {
|
||||||
|
return body.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(heading_title) = first_line[heading_len..].strip_prefix(' ') else {
|
||||||
|
return body.to_string();
|
||||||
|
};
|
||||||
|
if !heading_title.trim().eq_ignore_ascii_case(old_title.trim()) {
|
||||||
|
return body.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut retitled = String::with_capacity(body.len() + new_title.len());
|
||||||
|
retitled.push_str(&body[..leading_len]);
|
||||||
|
retitled.push_str(&"#".repeat(heading_len));
|
||||||
|
retitled.push(' ');
|
||||||
|
retitled.push_str(new_title);
|
||||||
|
retitled.push_str(&trimmed[first_line_end..]);
|
||||||
|
retitled
|
||||||
|
}
|
||||||
|
|
||||||
fn get_system_locale() -> Locale {
|
fn get_system_locale() -> Locale {
|
||||||
let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
|
let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
|
||||||
let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
|
let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
|
||||||
@@ -1240,7 +1309,7 @@ pub fn sanitize_filename(name: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{helixnotes_dir, load_notebook_icons, set_notebook_icon};
|
use super::{duplicate_note, helixnotes_dir, load_notebook_icons, set_notebook_icon};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -1263,4 +1332,36 @@ mod tests {
|
|||||||
|
|
||||||
fs::remove_dir_all(vault).unwrap();
|
fs::remove_dir_all(vault).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicates_note_content_and_assigns_unique_identity_and_name() {
|
||||||
|
let vault =
|
||||||
|
std::env::temp_dir().join(format!("helixnotes-duplicate-note-test-{}", Uuid::new_v4()));
|
||||||
|
let notebook = vault.join("Projects");
|
||||||
|
fs::create_dir_all(¬ebook).unwrap();
|
||||||
|
let source_path = notebook.join("Project.md");
|
||||||
|
let source_raw = "---\nid: source-id\ntitle: Project\ntags:\n - work\npinned: true\ncreated: 2020-01-01T00:00:00Z\nmodified: 2020-01-02T00:00:00Z\naliases:\n - Plan\n---\n# Project\n\nOriginal body.\n";
|
||||||
|
fs::write(&source_path, source_raw).unwrap();
|
||||||
|
|
||||||
|
let vault_path = vault.to_string_lossy();
|
||||||
|
let first = duplicate_note(&source_path.to_string_lossy(), &vault_path).unwrap();
|
||||||
|
let second = duplicate_note(&source_path.to_string_lossy(), &vault_path).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(first.meta.title, "Project copy");
|
||||||
|
assert_eq!(second.meta.title, "Project copy 2");
|
||||||
|
assert_eq!(first.meta.tags, vec!["work"]);
|
||||||
|
assert!(!first.meta.pinned);
|
||||||
|
assert_ne!(first.meta.id, "source-id");
|
||||||
|
assert_ne!(first.meta.id, second.meta.id);
|
||||||
|
assert_eq!(first.relative_path, "Projects/Project copy.md");
|
||||||
|
assert_eq!(second.relative_path, "Projects/Project copy 2.md");
|
||||||
|
|
||||||
|
let first_raw = fs::read_to_string(&first.path).unwrap();
|
||||||
|
assert!(first_raw.contains("aliases:\n- Plan"));
|
||||||
|
assert!(first_raw.contains("# Project copy\n\nOriginal body."));
|
||||||
|
assert!(!first_raw.contains("\n# Project\n"));
|
||||||
|
assert_eq!(fs::read_to_string(&source_path).unwrap(), source_raw);
|
||||||
|
|
||||||
|
fs::remove_dir_all(vault).unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"label": "main",
|
||||||
|
"visible": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,10 @@ export async function createNote(
|
|||||||
return invoke("create_note", { notebookRelative, title });
|
return invoke("create_note", { notebookRelative, title });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function duplicateNote(path: string): Promise<NoteEntry> {
|
||||||
|
return invoke("duplicate_note", { path });
|
||||||
|
}
|
||||||
|
|
||||||
export async function createDailyNote(date?: string): Promise<NoteEntry> {
|
export async function createDailyNote(date?: string): Promise<NoteEntry> {
|
||||||
return invoke("create_daily_note", { date: date ?? null });
|
return invoke("create_daily_note", { date: date ?? null });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -970,7 +970,7 @@
|
|||||||
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
|
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
|
||||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
|
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
|
||||||
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
|
<Editor bind:this={editor} onMoveToTrash={trashOpenNote} />
|
||||||
@@ -1031,7 +1031,7 @@
|
|||||||
|
|
||||||
{#if !$notelistCollapsed}
|
{#if !$notelistCollapsed}
|
||||||
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
||||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onBeforeNoteDuplicate={() => editor?.forceSave() ?? Promise.resolve(true)} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizeHandle onResize={handleNotelistResize} />
|
<ResizeHandle onResize={handleNotelistResize} />
|
||||||
|
|||||||
@@ -51,9 +51,11 @@
|
|||||||
import { calloutGroup, calloutIcon, calloutLabel, CALLOUT_MENU, transformCalloutBlockquotes, serializeCallout } from '$lib/editor/callouts';
|
import { calloutGroup, calloutIcon, calloutLabel, CALLOUT_MENU, transformCalloutBlockquotes, serializeCallout } from '$lib/editor/callouts';
|
||||||
import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs';
|
import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs';
|
||||||
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
|
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
|
||||||
|
import { serializeInlineMarkdown } from '$lib/editor/markdown';
|
||||||
import { relativePath } from '$lib/utils/paths';
|
import { relativePath } from '$lib/utils/paths';
|
||||||
import GraphView from './GraphView.svelte';
|
import GraphView from './GraphView.svelte';
|
||||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||||
|
import ImageViewer from './ImageViewer.svelte';
|
||||||
import { isMobile, isAndroid } from '$lib/platform';
|
import { isMobile, isAndroid } from '$lib/platform';
|
||||||
import ResizeHandle from './ResizeHandle.svelte';
|
import ResizeHandle from './ResizeHandle.svelte';
|
||||||
|
|
||||||
@@ -470,7 +472,8 @@
|
|||||||
let tableContextMenu = $state<{ x: number; y: number; hasStyling: boolean } | null>(null);
|
let tableContextMenu = $state<{ x: number; y: number; hasStyling: boolean } | null>(null);
|
||||||
let tablePickerOpen = $state(false);
|
let tablePickerOpen = $state(false);
|
||||||
let tablePickerHover = $state({ rows: 0, cols: 0 });
|
let tablePickerHover = $state({ rows: 0, cols: 0 });
|
||||||
let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string } | null>(null);
|
let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string; alt: string } | null>(null);
|
||||||
|
let imageViewer = $state<{ src: string; alt: string } | null>(null);
|
||||||
let copyToast = $state<'copying' | 'done' | null>(null);
|
let copyToast = $state<'copying' | 'done' | null>(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -3666,68 +3669,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function serializeInline(node: any): string {
|
function serializeInline(node: any): string {
|
||||||
if (node.childCount === 0) return '';
|
return serializeInlineMarkdown(node, stripAssetSrc);
|
||||||
const parts: string[] = [];
|
|
||||||
node.forEach((child: any, _offset: number, index: number) => {
|
|
||||||
if (child.isText) {
|
|
||||||
let text = child.text || '';
|
|
||||||
// Preserve leading tabs/em-spaces as HTML entities so they survive markdown roundtrip
|
|
||||||
// (markdown parsers strip tab whitespace, but   passes through as HTML)
|
|
||||||
// Tabs come from initial indent; em-spaces (U+2003) come from prior   roundtrips
|
|
||||||
if (index === 0) {
|
|
||||||
text = text.replace(/^[\t\u2003]+/, (ws) => ' '.repeat(ws.length));
|
|
||||||
}
|
|
||||||
// Apply marks
|
|
||||||
for (const mark of child.marks) {
|
|
||||||
switch (mark.type.name) {
|
|
||||||
case 'bold': text = `**${text}**`; break;
|
|
||||||
case 'italic': text = `*${text}*`; break;
|
|
||||||
case 'strike': text = `~~${text}~~`; break;
|
|
||||||
case 'code': text = `\`${text}\``; break;
|
|
||||||
case 'underline': text = `<u>${text}</u>`; break;
|
|
||||||
case 'subscript': text = `~${text}~`; break;
|
|
||||||
case 'superscript': text = `^${text}^`; break;
|
|
||||||
case 'highlight': {
|
|
||||||
const color = mark.attrs?.color;
|
|
||||||
if (color) {
|
|
||||||
text = `<mark data-color="${color}">${text}</mark>`;
|
|
||||||
} else {
|
|
||||||
text = `==${text}==`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'textStyle': {
|
|
||||||
const c = mark.attrs?.color;
|
|
||||||
if (c) text = `<span style="color: ${c}">${text}</span>`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'link': text = `[${text}](${mark.attrs.href})`; break;
|
|
||||||
case 'wikiLink': {
|
|
||||||
const wlTitle = mark.attrs.title || text;
|
|
||||||
// If display text differs from the reference, emit [[ref|display]] (Obsidian alias syntax)
|
|
||||||
text = wlTitle !== text ? `[[${wlTitle}|${text}]]` : `[[${wlTitle}]]`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parts.push(text);
|
|
||||||
} else if (child.type.name === 'image') {
|
|
||||||
const src = stripAssetSrc(child.attrs.src || '');
|
|
||||||
if (!src) return; // Skip images with unresolved blob: URLs
|
|
||||||
const alt = child.attrs.alt || '';
|
|
||||||
const size = child.attrs['data-size'] || child.attrs.size || 'full';
|
|
||||||
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
|
|
||||||
if (parts.length > 0 && parts[parts.length - 1] !== '\n') {
|
|
||||||
parts.push('\n');
|
|
||||||
}
|
|
||||||
parts.push(``);
|
|
||||||
} else if (child.type.name === 'mathInline') {
|
|
||||||
parts.push(`$${child.attrs.tex || ''}$`);
|
|
||||||
} else if (child.type.name === 'hardBreak') {
|
|
||||||
parts.push(' \n');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return parts.join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function autofocus(el: HTMLElement) {
|
function autofocus(el: HTMLElement) {
|
||||||
@@ -4888,11 +4830,12 @@
|
|||||||
const node = editor.state.doc.nodeAt(pos);
|
const node = editor.state.doc.nodeAt(pos);
|
||||||
const currentSize = node?.attrs.size || 'full';
|
const currentSize = node?.attrs.size || 'full';
|
||||||
const imgSrc = node?.attrs.src || (target as HTMLImageElement).src || '';
|
const imgSrc = node?.attrs.src || (target as HTMLImageElement).src || '';
|
||||||
const toolbarW = isMobile ? 130 : 250;
|
const imgAlt = node?.attrs.alt || (target as HTMLImageElement).alt || '';
|
||||||
const toolbarH = 38;
|
const toolbarW = isAndroid ? 188 : isMobile ? 130 : 250;
|
||||||
const x = Math.min(event.clientX, window.innerWidth - toolbarW - 8);
|
const toolbarH = isAndroid ? 48 : 38;
|
||||||
const y = Math.min(event.clientY, window.innerHeight - toolbarH - 8);
|
const x = Math.max(8, Math.min(event.clientX, window.innerWidth - toolbarW - 8));
|
||||||
imageToolbar = { pos, x, y, size: currentSize, src: imgSrc };
|
const y = Math.max(8, Math.min(event.clientY, window.innerHeight - toolbarH - 8));
|
||||||
|
imageToolbar = { pos, x, y, size: currentSize, src: imgSrc, alt: imgAlt };
|
||||||
// Move cursor after the image to clear ProseMirror's node selection highlight
|
// Move cursor after the image to clear ProseMirror's node selection highlight
|
||||||
const afterPos = pos + (node?.nodeSize || 1);
|
const afterPos = pos + (node?.nodeSize || 1);
|
||||||
editor.chain().setTextSelection(afterPos).run();
|
editor.chain().setTextSelection(afterPos).run();
|
||||||
@@ -4902,6 +4845,12 @@
|
|||||||
imageToolbar = null;
|
imageToolbar = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openImageViewer() {
|
||||||
|
if (!imageToolbar) return;
|
||||||
|
imageViewer = { src: imageToolbar.src, alt: imageToolbar.alt };
|
||||||
|
imageToolbar = null;
|
||||||
|
}
|
||||||
|
|
||||||
function setImageSize(size: string) {
|
function setImageSize(size: string) {
|
||||||
if (!editor || !imageToolbar) return;
|
if (!editor || !imageToolbar) return;
|
||||||
const { pos } = imageToolbar;
|
const { pos } = imageToolbar;
|
||||||
@@ -6235,6 +6184,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<button type="button" class="icon-btn editor-trash-btn" onclick={moveOpenNoteToTrash} disabled={trashingNote} title="Move to Trash" aria-label="Move note to Trash">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 6h18"/><path d="M19 6v14H5V6"/><path d="M8 6V4h8v2"/><path d="M10 11v5"/><path d="M14 11v5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="icon-btn"
|
class="icon-btn"
|
||||||
class:active={$sourceMode}
|
class:active={$sourceMode}
|
||||||
@@ -6245,11 +6199,6 @@
|
|||||||
<path d="M5.854 4.854a.5.5 0 10-.708-.708l-3.5 3.5a.5.5 0 000 .708l3.5 3.5a.5.5 0 00.708-.708L2.707 8l3.147-3.146zm4.292 0a.5.5 0 01.708-.708l3.5 3.5a.5.5 0 010 .708l-3.5 3.5a.5.5 0 01-.708-.708L13.293 8l-3.147-3.146z" />
|
<path d="M5.854 4.854a.5.5 0 10-.708-.708l-3.5 3.5a.5.5 0 000 .708l3.5 3.5a.5.5 0 00.708-.708L2.707 8l3.147-3.146zm4.292 0a.5.5 0 01.708-.708l3.5 3.5a.5.5 0 010 .708l-3.5 3.5a.5.5 0 01-.708-.708L13.293 8l-3.147-3.146z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="icon-btn editor-trash-btn" onclick={moveOpenNoteToTrash} disabled={trashingNote} title="Move to Trash" aria-label="Move note to Trash">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M3 6h18"/><path d="M19 6v14H5V6"/><path d="M8 6V4h8v2"/><path d="M10 11v5"/><path d="M14 11v5"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -7405,10 +7354,16 @@
|
|||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="img-toolbar-overlay" onclick={() => (imageToolbar = null)}>
|
<div class="img-toolbar-overlay" onclick={() => (imageToolbar = null)}>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div class="img-toolbar" style="left: {imageToolbar.x}px; top: {imageToolbar.y}px" onclick={(e) => e.stopPropagation()}>
|
<div class="img-toolbar" class:mobile-viewer-toolbar={isAndroid} style="left: {imageToolbar.x}px; top: {imageToolbar.y}px" onclick={(e) => e.stopPropagation()}>
|
||||||
<button class:active={imageToolbar.size === 'small'} onclick={() => setImageSize('small')} title="Small (33%)">S</button>
|
<button class:active={imageToolbar.size === 'small'} onclick={() => setImageSize('small')} title="Small (33%)">S</button>
|
||||||
<button class:active={imageToolbar.size === 'medium'} onclick={() => setImageSize('medium')} title="Medium (50%)">M</button>
|
<button class:active={imageToolbar.size === 'medium'} onclick={() => setImageSize('medium')} title="Medium (50%)">M</button>
|
||||||
<button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button>
|
<button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button>
|
||||||
|
{#if isAndroid}
|
||||||
|
<span class="img-toolbar-sep"></span>
|
||||||
|
<button class="img-toolbar-view" onclick={openImageViewer} title="View and zoom image" aria-label="View and zoom image">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 00-2 2v3M16 3h3a2 2 0 012 2v3M8 21H5a2 2 0 01-2-2v-3M16 21h3a2 2 0 002-2v-3"/></svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if !isMobile && !imageToolbar.src.startsWith('imgproxy:') && !imageToolbar.src.startsWith('http://imgproxy.localhost')}
|
{#if !isMobile && !imageToolbar.src.startsWith('imgproxy:') && !imageToolbar.src.startsWith('http://imgproxy.localhost')}
|
||||||
<span class="img-toolbar-sep"></span>
|
<span class="img-toolbar-sep"></span>
|
||||||
<button onclick={copyImageToClipboard} title="Copy image">
|
<button onclick={copyImageToClipboard} title="Copy image">
|
||||||
@@ -7422,6 +7377,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if imageViewer}
|
||||||
|
<ImageViewer src={imageViewer.src} alt={imageViewer.alt} onclose={() => (imageViewer = null)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if copyToast}
|
{#if copyToast}
|
||||||
<div class="copy-toast" class:done={copyToast === 'done'}>
|
<div class="copy-toast" class:done={copyToast === 'done'}>
|
||||||
{#if copyToast === 'copying'}
|
{#if copyToast === 'copying'}
|
||||||
@@ -10063,6 +10022,12 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.img-toolbar.mobile-viewer-toolbar button {
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.copy-toast {
|
.copy-toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let { src, alt = '', onclose }: {
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
onclose: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const MIN_SCALE = 1;
|
||||||
|
const MAX_SCALE = 5;
|
||||||
|
const DOUBLE_TAP_MS = 300;
|
||||||
|
const TAP_MOVE_THRESHOLD = 6;
|
||||||
|
|
||||||
|
type Point = { x: number; y: number };
|
||||||
|
type ViewerGesture =
|
||||||
|
| { kind: 'pan'; start: Point; pan: Point }
|
||||||
|
| { kind: 'pinch'; distance: number; imagePoint: Point };
|
||||||
|
|
||||||
|
let canvasElement = $state<HTMLDivElement | null>(null);
|
||||||
|
let imageElement = $state<HTMLImageElement | null>(null);
|
||||||
|
let closeButton = $state<HTMLButtonElement | null>(null);
|
||||||
|
let scale = $state(1);
|
||||||
|
let panX = $state(0);
|
||||||
|
let panY = $state(0);
|
||||||
|
let interacting = $state(false);
|
||||||
|
let gesture: ViewerGesture | null = null;
|
||||||
|
let touchMoved = false;
|
||||||
|
let lastTapAt = 0;
|
||||||
|
let zoomPercent = $derived(Math.round(scale * 100));
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function midpoint(first: Touch, second: Touch): Point {
|
||||||
|
return {
|
||||||
|
x: (first.clientX + second.clientX) / 2,
|
||||||
|
y: (first.clientY + second.clientY) / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function touchDistance(first: Touch, second: Touch): number {
|
||||||
|
return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function panLimits(nextScale: number): Point {
|
||||||
|
const canvasWidth = canvasElement?.clientWidth ?? window.innerWidth;
|
||||||
|
const canvasHeight = canvasElement?.clientHeight ?? window.innerHeight;
|
||||||
|
const imageWidth = imageElement?.clientWidth ?? 0;
|
||||||
|
const imageHeight = imageElement?.clientHeight ?? 0;
|
||||||
|
return {
|
||||||
|
x: Math.max(0, (imageWidth * nextScale - canvasWidth) / 2),
|
||||||
|
y: Math.max(0, (imageHeight * nextScale - canvasHeight) / 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTransform(nextScale: number, nextPanX: number, nextPanY: number) {
|
||||||
|
const clampedScale = clamp(nextScale, MIN_SCALE, MAX_SCALE);
|
||||||
|
const limits = panLimits(clampedScale);
|
||||||
|
scale = clampedScale;
|
||||||
|
panX = clamp(nextPanX, -limits.x, limits.x);
|
||||||
|
panY = clamp(nextPanY, -limits.y, limits.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetViewer() {
|
||||||
|
interacting = false;
|
||||||
|
gesture = null;
|
||||||
|
applyTransform(MIN_SCALE, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomAt(point: Point, nextScale: number) {
|
||||||
|
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||||
|
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||||
|
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||||
|
const imageX = (point.x - centerX - panX) / scale;
|
||||||
|
const imageY = (point.y - centerY - panY) / scale;
|
||||||
|
applyTransform(
|
||||||
|
nextScale,
|
||||||
|
point.x - centerX - imageX * nextScale,
|
||||||
|
point.y - centerY - imageY * nextScale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginPan(touch: Touch) {
|
||||||
|
gesture = {
|
||||||
|
kind: 'pan',
|
||||||
|
start: { x: touch.clientX, y: touch.clientY },
|
||||||
|
pan: { x: panX, y: panY },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginPinch(first: Touch, second: Touch) {
|
||||||
|
const point = midpoint(first, second);
|
||||||
|
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||||
|
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||||
|
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||||
|
gesture = {
|
||||||
|
kind: 'pinch',
|
||||||
|
distance: Math.max(1, touchDistance(first, second)),
|
||||||
|
imagePoint: {
|
||||||
|
x: (point.x - centerX - panX) / scale,
|
||||||
|
y: (point.y - centerY - panY) / scale,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchStart(event: TouchEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
interacting = true;
|
||||||
|
if (event.touches.length >= 2) {
|
||||||
|
touchMoved = true;
|
||||||
|
beginPinch(event.touches[0], event.touches[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.touches.length === 1) {
|
||||||
|
touchMoved = false;
|
||||||
|
beginPan(event.touches[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(event: TouchEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.touches.length >= 2) {
|
||||||
|
if (gesture?.kind !== 'pinch') beginPinch(event.touches[0], event.touches[1]);
|
||||||
|
if (gesture?.kind !== 'pinch') return;
|
||||||
|
const point = midpoint(event.touches[0], event.touches[1]);
|
||||||
|
const canvasRect = canvasElement?.getBoundingClientRect();
|
||||||
|
const centerX = canvasRect ? canvasRect.left + canvasRect.width / 2 : window.innerWidth / 2;
|
||||||
|
const centerY = canvasRect ? canvasRect.top + canvasRect.height / 2 : window.innerHeight / 2;
|
||||||
|
const nextScale = clamp(
|
||||||
|
scale * (touchDistance(event.touches[0], event.touches[1]) / gesture.distance),
|
||||||
|
MIN_SCALE,
|
||||||
|
MAX_SCALE,
|
||||||
|
);
|
||||||
|
gesture.distance = Math.max(1, touchDistance(event.touches[0], event.touches[1]));
|
||||||
|
applyTransform(
|
||||||
|
nextScale,
|
||||||
|
point.x - centerX - gesture.imagePoint.x * nextScale,
|
||||||
|
point.y - centerY - gesture.imagePoint.y * nextScale,
|
||||||
|
);
|
||||||
|
touchMoved = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.touches.length === 1 && gesture?.kind === 'pan') {
|
||||||
|
const dx = event.touches[0].clientX - gesture.start.x;
|
||||||
|
const dy = event.touches[0].clientY - gesture.start.y;
|
||||||
|
if (Math.hypot(dx, dy) > TAP_MOVE_THRESHOLD) touchMoved = true;
|
||||||
|
applyTransform(scale, gesture.pan.x + dx, gesture.pan.y + dy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd(event: TouchEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.touches.length >= 2) {
|
||||||
|
beginPinch(event.touches[0], event.touches[1]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.touches.length === 1) {
|
||||||
|
beginPan(event.touches[0]);
|
||||||
|
touchMoved = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
interacting = false;
|
||||||
|
gesture = null;
|
||||||
|
if (!touchMoved && event.changedTouches.length > 0) {
|
||||||
|
const now = performance.now();
|
||||||
|
const touch = event.changedTouches[0];
|
||||||
|
if (now - lastTapAt <= DOUBLE_TAP_MS) {
|
||||||
|
if (scale > MIN_SCALE + 0.01) resetViewer();
|
||||||
|
else zoomAt({ x: touch.clientX, y: touch.clientY }, 2);
|
||||||
|
lastTapAt = 0;
|
||||||
|
} else {
|
||||||
|
lastTapAt = now;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchCancel(event: TouchEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
interacting = false;
|
||||||
|
gesture = null;
|
||||||
|
touchMoved = false;
|
||||||
|
applyTransform(scale, panX, panY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachTouchGestures(node: HTMLElement) {
|
||||||
|
node.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||||
|
node.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
node.addEventListener('touchend', handleTouchEnd, { passive: false });
|
||||||
|
node.addEventListener('touchcancel', handleTouchCancel, { passive: false });
|
||||||
|
return {
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener('touchstart', handleTouchStart);
|
||||||
|
node.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
node.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
node.removeEventListener('touchcancel', handleTouchCancel);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
closeButton?.focus();
|
||||||
|
const handleResize = () => applyTransform(scale, panX, panY);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="image-viewer"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={alt ? `Image viewer: ${alt}` : 'Image viewer'}
|
||||||
|
tabindex="-1"
|
||||||
|
onkeydown={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="image-viewer-canvas"
|
||||||
|
bind:this={canvasElement}
|
||||||
|
use:attachTouchGestures
|
||||||
|
onclick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) onclose();
|
||||||
|
}}
|
||||||
|
onkeydown={(event) => {
|
||||||
|
if (event.target === event.currentTarget && (event.key === 'Enter' || event.key === ' ')) onclose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
bind:this={imageElement}
|
||||||
|
class:interacting
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
draggable="false"
|
||||||
|
style:transform={`translate3d(${panX}px, ${panY}px, 0) scale(${scale})`}
|
||||||
|
onload={resetViewer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="image-viewer-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="image-viewer-reset"
|
||||||
|
onclick={resetViewer}
|
||||||
|
disabled={scale === MIN_SCALE && panX === 0 && panY === 0}
|
||||||
|
aria-label={`Reset zoom, currently ${zoomPercent}%`}
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
||||||
|
<path d="M3 4v6h6" />
|
||||||
|
</svg>
|
||||||
|
<span>{zoomPercent}%</span>
|
||||||
|
</button>
|
||||||
|
<button bind:this={closeButton} type="button" class="image-viewer-close" onclick={onclose} aria-label="Close image viewer">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<path d="M18 6 6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="image-viewer-hint" aria-hidden="true">Pinch to zoom · drag to move · double-tap to zoom or reset</div>
|
||||||
|
<span class="sr-only" aria-live="polite">Zoom {zoomPercent}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.image-viewer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
background: #06080c;
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-canvas img {
|
||||||
|
display: block;
|
||||||
|
max-width: calc(100vw - 24px);
|
||||||
|
max-height: calc(100dvh - 24px);
|
||||||
|
object-fit: contain;
|
||||||
|
transform-origin: center center;
|
||||||
|
transition: transform 160ms ease-out;
|
||||||
|
will-change: transform;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-canvas img.interacting {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-controls {
|
||||||
|
position: absolute;
|
||||||
|
inset: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) auto max(12px, env(safe-area-inset-left));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-controls button {
|
||||||
|
min-height: 44px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(20, 23, 30, 0.86);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-reset {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-reset:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-close {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-hint {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: max(18px, calc(env(safe-area-inset-bottom) + 10px));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: max-content;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(20, 23, 30, 0.82);
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.image-viewer-canvas img {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
getNotes,
|
getNotes,
|
||||||
readNote,
|
readNote,
|
||||||
createNote,
|
createNote,
|
||||||
|
duplicateNote,
|
||||||
deleteNote,
|
deleteNote,
|
||||||
renameNote,
|
renameNote,
|
||||||
saveNote,
|
saveNote,
|
||||||
@@ -47,10 +48,11 @@
|
|||||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||||
import { isMobile, isAndroid } from '$lib/platform';
|
import { isMobile, isAndroid } from '$lib/platform';
|
||||||
|
|
||||||
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onBeforeNoteDuplicate = async () => true, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
||||||
onNoteSelected?: (path: string, content: string, task?: TaskItem) => void;
|
onNoteSelected?: (path: string, content: string, task?: TaskItem) => void;
|
||||||
onNoteMoved?: () => void;
|
onNoteMoved?: () => void;
|
||||||
onBeforeNoteSwitch?: () => void;
|
onBeforeNoteSwitch?: () => void;
|
||||||
|
onBeforeNoteDuplicate?: () => Promise<boolean>;
|
||||||
onNoteCreated?: () => void;
|
onNoteCreated?: () => void;
|
||||||
onToggleTask?: (t: TaskItem) => Promise<void>;
|
onToggleTask?: (t: TaskItem) => Promise<void>;
|
||||||
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
|
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
|
||||||
@@ -468,6 +470,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDuplicate(note: NoteEntry) {
|
||||||
|
contextMenu = null;
|
||||||
|
try {
|
||||||
|
if ($activeNotePath === note.path && $editorDirty) {
|
||||||
|
const saved = await onBeforeNoteDuplicate();
|
||||||
|
if (!saved) {
|
||||||
|
console.error('Failed to save note before duplicating');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await duplicateNote(note.path);
|
||||||
|
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
||||||
|
noteCache.clear();
|
||||||
|
if ($viewMode !== 'quickaccess') $notes = [entry, ...$notes];
|
||||||
|
await selectNote(entry);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to duplicate note:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleListDoubleClick(e: MouseEvent) {
|
function handleListDoubleClick(e: MouseEvent) {
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
|
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
|
||||||
@@ -1385,7 +1408,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if contextMenu}
|
{#if contextMenu}
|
||||||
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu">
|
<div class="context-menu" class:mobile={isMobile} style="left: {contextMenu.x}px; top: {contextMenu.y}px" onclick={(e) => e.stopPropagation()} role="menu">
|
||||||
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
|
{#if selectedPaths.size > 1 && selectedPaths.has(contextMenu.note.path)}
|
||||||
<!-- Batch context menu -->
|
<!-- Batch context menu -->
|
||||||
{#if $viewMode === 'trash'}
|
{#if $viewMode === 'trash'}
|
||||||
@@ -1549,6 +1572,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Rename
|
Rename
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick={() => handleDuplicate(contextMenu!.note)}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 00-2-2H5a2 2 0 00-2 2v9a2 2 0 002 2h3"/>
|
||||||
|
</svg>
|
||||||
|
Duplicate Note
|
||||||
|
</button>
|
||||||
<button onclick={() => { movePickerNote = contextMenu!.note; }}>
|
<button onclick={() => { movePickerNote = contextMenu!.note; }}>
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
|
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
|
||||||
@@ -2291,13 +2320,15 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-list.mobile .context-menu {
|
.context-menu.mobile {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
max-height: calc(100dvh - 16px);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-list.mobile .context-menu button {
|
.context-menu.mobile button {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { Mark, Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||||
|
|
||||||
|
type AssetSourceNormalizer = (source: string) => string;
|
||||||
|
|
||||||
|
type MarkdownMark = {
|
||||||
|
key: string;
|
||||||
|
priority: number;
|
||||||
|
open: string;
|
||||||
|
close: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const identityAssetSource: AssetSourceNormalizer = (source) => source;
|
||||||
|
|
||||||
|
function stringAttr(mark: Mark, name: string): string {
|
||||||
|
const value = mark.attrs?.[name];
|
||||||
|
return typeof value === 'string' ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownMark(mark: Mark, text: string): MarkdownMark | null {
|
||||||
|
switch (mark.type.name) {
|
||||||
|
case 'bold':
|
||||||
|
return { key: 'bold', priority: 10, open: '**', close: '**' };
|
||||||
|
case 'italic':
|
||||||
|
return { key: 'italic', priority: 20, open: '*', close: '*' };
|
||||||
|
case 'strike':
|
||||||
|
return { key: 'strike', priority: 30, open: '~~', close: '~~' };
|
||||||
|
case 'underline':
|
||||||
|
return { key: 'underline', priority: 40, open: '<u>', close: '</u>' };
|
||||||
|
case 'subscript':
|
||||||
|
return { key: 'subscript', priority: 50, open: '~', close: '~' };
|
||||||
|
case 'superscript':
|
||||||
|
return { key: 'superscript', priority: 60, open: '^', close: '^' };
|
||||||
|
case 'highlight': {
|
||||||
|
const color = stringAttr(mark, 'color');
|
||||||
|
return color
|
||||||
|
? {
|
||||||
|
key: `highlight:${color}`,
|
||||||
|
priority: 70,
|
||||||
|
open: `<mark data-color="${color}">`,
|
||||||
|
close: '</mark>',
|
||||||
|
}
|
||||||
|
: { key: 'highlight', priority: 70, open: '==', close: '==' };
|
||||||
|
}
|
||||||
|
case 'textStyle': {
|
||||||
|
const color = stringAttr(mark, 'color');
|
||||||
|
return color
|
||||||
|
? {
|
||||||
|
key: `textStyle:${color}`,
|
||||||
|
priority: 80,
|
||||||
|
open: `<span style="color: ${color}">`,
|
||||||
|
close: '</span>',
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
case 'code':
|
||||||
|
return { key: 'code', priority: 90, open: '`', close: '`' };
|
||||||
|
case 'link': {
|
||||||
|
const href = stringAttr(mark, 'href');
|
||||||
|
return {
|
||||||
|
key: `link:${href}`,
|
||||||
|
priority: 100,
|
||||||
|
open: '[',
|
||||||
|
close: `](${href})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'wikiLink': {
|
||||||
|
const title = stringAttr(mark, 'title') || text;
|
||||||
|
const aliased = mark.attrs?.aliased === true || title !== text;
|
||||||
|
return {
|
||||||
|
key: `wikiLink:${title}:${aliased}`,
|
||||||
|
priority: 110,
|
||||||
|
open: aliased ? `[[${title}|` : '[[',
|
||||||
|
close: ']]',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function marksForText(node: ProseMirrorNode): MarkdownMark[] {
|
||||||
|
return node.marks
|
||||||
|
.map((mark) => markdownMark(mark, node.text || ''))
|
||||||
|
.filter((mark): mark is MarkdownMark => mark !== null)
|
||||||
|
.sort((left, right) => left.priority - right.priority || left.key.localeCompare(right.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function commonMarkCount(active: MarkdownMark[], next: MarkdownMark[]): number {
|
||||||
|
const length = Math.min(active.length, next.length);
|
||||||
|
let index = 0;
|
||||||
|
while (index < length && active[index].key === next[index].key) index += 1;
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeInlineMarkdown(
|
||||||
|
node: ProseMirrorNode,
|
||||||
|
normalizeAssetSource: AssetSourceNormalizer = identityAssetSource,
|
||||||
|
): string {
|
||||||
|
let result = '';
|
||||||
|
let activeMarks: MarkdownMark[] = [];
|
||||||
|
|
||||||
|
const transitionMarks = (nextMarks: MarkdownMark[]) => {
|
||||||
|
const shared = commonMarkCount(activeMarks, nextMarks);
|
||||||
|
for (let index = activeMarks.length - 1; index >= shared; index -= 1) {
|
||||||
|
result += activeMarks[index].close;
|
||||||
|
}
|
||||||
|
for (let index = shared; index < nextMarks.length; index += 1) {
|
||||||
|
result += nextMarks[index].open;
|
||||||
|
}
|
||||||
|
activeMarks = nextMarks;
|
||||||
|
};
|
||||||
|
|
||||||
|
node.forEach((child, _offset, index) => {
|
||||||
|
if (child.isText) {
|
||||||
|
transitionMarks(marksForText(child));
|
||||||
|
let text = child.text || '';
|
||||||
|
if (index === 0) {
|
||||||
|
text = text.replace(/^[\t\u2003]+/, (whitespace) => ' '.repeat(whitespace.length));
|
||||||
|
}
|
||||||
|
result += text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
transitionMarks([]);
|
||||||
|
|
||||||
|
if (child.type.name === 'image') {
|
||||||
|
const source = normalizeAssetSource(child.attrs.src || '');
|
||||||
|
if (!source) return;
|
||||||
|
const alt = child.attrs.alt || '';
|
||||||
|
const size = child.attrs['data-size'] || child.attrs.size || 'full';
|
||||||
|
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
|
||||||
|
if (result) result += '\n';
|
||||||
|
result += ``;
|
||||||
|
} else if (child.type.name === 'mathInline') {
|
||||||
|
result += `$${child.attrs.tex || ''}$`;
|
||||||
|
} else if (child.type.name === 'hardBreak') {
|
||||||
|
result += ' \n';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
transitionMarks([]);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
+37
-3
@@ -1,10 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount, tick } from 'svelte';
|
||||||
import { appConfig, vaultReady, theme } from '$lib/stores/app';
|
import { appConfig, vaultReady, theme } from '$lib/stores/app';
|
||||||
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
|
import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
|
||||||
import { darkThemes, isIOS } from '$lib/platform';
|
import { darkThemes, isIOS, isMobile } from '$lib/platform';
|
||||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
|
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||||
import VaultPicker from '$lib/components/VaultPicker.svelte';
|
import VaultPicker from '$lib/components/VaultPicker.svelte';
|
||||||
import AppLayout from '$lib/components/AppLayout.svelte';
|
import AppLayout from '$lib/components/AppLayout.svelte';
|
||||||
import NoteWindow from '$lib/components/NoteWindow.svelte';
|
import NoteWindow from '$lib/components/NoteWindow.svelte';
|
||||||
@@ -15,6 +16,28 @@
|
|||||||
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let removeEditorZoomShortcuts: (() => void) | null = null;
|
let removeEditorZoomShortcuts: (() => void) | null = null;
|
||||||
let removeFontSizeListener: (() => void) | null = null;
|
let removeFontSizeListener: (() => void) | null = null;
|
||||||
|
let startupRevealTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let startupWindowRevealed = false;
|
||||||
|
|
||||||
|
const STARTUP_REVEAL_FAILSAFE_MS = 4000;
|
||||||
|
|
||||||
|
async function revealStartupWindow() {
|
||||||
|
if (isMobile || startupWindowRevealed) return;
|
||||||
|
await tick();
|
||||||
|
// Force style resolution while the native window is still hidden so its first
|
||||||
|
// compositor frame already uses the selected theme.
|
||||||
|
getComputedStyle(document.body).backgroundColor;
|
||||||
|
try {
|
||||||
|
await getCurrentWindow().show();
|
||||||
|
startupWindowRevealed = true;
|
||||||
|
if (startupRevealTimer) {
|
||||||
|
clearTimeout(startupRevealTimer);
|
||||||
|
startupRevealTimer = null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Plain-browser development has no Tauri window to reveal.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const defaultEditorFontSize = 14;
|
const defaultEditorFontSize = 14;
|
||||||
const minEditorFontSize = 10;
|
const minEditorFontSize = 10;
|
||||||
@@ -114,6 +137,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
if (!isMobile) {
|
||||||
|
startupRevealTimer = setTimeout(() => void revealStartupWindow(), STARTUP_REVEAL_FAILSAFE_MS);
|
||||||
|
}
|
||||||
// Check for note window mode
|
// Check for note window mode
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const notePath = params.get('note');
|
const notePath = params.get('note');
|
||||||
@@ -209,6 +235,7 @@
|
|||||||
console.error('Failed to apply interface scale:', e);
|
console.error('Failed to apply interface scale:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await revealStartupWindow();
|
||||||
removeEditorZoomShortcuts = installEditorZoomShortcuts();
|
removeEditorZoomShortcuts = installEditorZoomShortcuts();
|
||||||
|
|
||||||
// Auto-open last vault if available
|
// Auto-open last vault if available
|
||||||
@@ -242,14 +269,21 @@
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// First launch or no config
|
// First launch or no config
|
||||||
}
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
|
await revealStartupWindow();
|
||||||
|
if (startupRevealTimer) {
|
||||||
|
clearTimeout(startupRevealTimer);
|
||||||
|
startupRevealTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
removeEditorZoomShortcuts?.();
|
removeEditorZoomShortcuts?.();
|
||||||
removeFontSizeListener?.();
|
removeFontSizeListener?.();
|
||||||
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
||||||
|
if (startupRevealTimer) clearTimeout(startupRevealTimer);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
import MarkdownIt from 'markdown-it';
|
||||||
|
import { Schema } from '@tiptap/pm/model';
|
||||||
|
import { transformWithEsbuild } from 'vite';
|
||||||
|
|
||||||
|
const source = await readFile(
|
||||||
|
new URL('../src/lib/editor/markdown.ts', import.meta.url),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const { code } = await transformWithEsbuild(source, 'markdown.ts', {
|
||||||
|
loader: 'ts',
|
||||||
|
format: 'esm',
|
||||||
|
target: 'esnext'
|
||||||
|
});
|
||||||
|
const { serializeInlineMarkdown } = await import(
|
||||||
|
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Link intentionally precedes formatting marks in the schema. This matches the
|
||||||
|
// mark order produced by the app and reproduces the original per-text-node bug.
|
||||||
|
const schema = new Schema({
|
||||||
|
nodes: {
|
||||||
|
doc: { content: 'block+' },
|
||||||
|
paragraph: { content: 'text*', group: 'block' },
|
||||||
|
text: { group: 'inline' }
|
||||||
|
},
|
||||||
|
marks: {
|
||||||
|
link: { attrs: { href: {} } },
|
||||||
|
bold: {},
|
||||||
|
italic: {},
|
||||||
|
wikiLink: {
|
||||||
|
attrs: {
|
||||||
|
title: { default: null },
|
||||||
|
aliased: { default: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const markdown = new MarkdownIt({ html: true, linkify: false, breaks: false });
|
||||||
|
const bold = schema.marks.bold.create();
|
||||||
|
const italic = schema.marks.italic.create();
|
||||||
|
const link = schema.marks.link.create({ href: 'https://example.com' });
|
||||||
|
|
||||||
|
function paragraph(segments) {
|
||||||
|
return schema.node(
|
||||||
|
'paragraph',
|
||||||
|
null,
|
||||||
|
segments.map(({ text, marks }) => schema.text(text, marks))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertMarkdown(segments, expected, expectedHtml) {
|
||||||
|
const serialized = serializeInlineMarkdown(paragraph(segments));
|
||||||
|
assert.equal(serialized, expected);
|
||||||
|
assert.equal(markdown.render(serialized), `${expectedHtml}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('keeps bold open when a link ends the marked span', () => {
|
||||||
|
assertMarkdown(
|
||||||
|
[
|
||||||
|
{ text: 'Bold ', marks: [bold] },
|
||||||
|
{ text: 'link', marks: [link, bold] }
|
||||||
|
],
|
||||||
|
'**Bold [link](https://example.com)**',
|
||||||
|
'<p><strong>Bold <a href="https://example.com">link</a></strong></p>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps bold open when a link starts the marked span', () => {
|
||||||
|
assertMarkdown(
|
||||||
|
[
|
||||||
|
{ text: 'link', marks: [link, bold] },
|
||||||
|
{ text: ' bold', marks: [bold] }
|
||||||
|
],
|
||||||
|
'**[link](https://example.com) bold**',
|
||||||
|
'<p><strong><a href="https://example.com">link</a> bold</strong></p>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps one bold span around a link in the middle', () => {
|
||||||
|
assertMarkdown(
|
||||||
|
[
|
||||||
|
{ text: 'Before ', marks: [bold] },
|
||||||
|
{ text: 'link', marks: [link, bold] },
|
||||||
|
{ text: ' after', marks: [bold] }
|
||||||
|
],
|
||||||
|
'**Before [link](https://example.com) after**',
|
||||||
|
'<p><strong>Before <a href="https://example.com">link</a> after</strong></p>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps nested bold and italic marks open across a link', () => {
|
||||||
|
assertMarkdown(
|
||||||
|
[
|
||||||
|
{ text: 'Nested ', marks: [bold, italic] },
|
||||||
|
{ text: 'link', marks: [link, bold, italic] },
|
||||||
|
{ text: ' marks', marks: [bold, italic] }
|
||||||
|
],
|
||||||
|
'***Nested [link](https://example.com) marks***',
|
||||||
|
'<p><em><strong>Nested <a href="https://example.com">link</a> marks</strong></em></p>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves wiki-link aliases inside a bold span', () => {
|
||||||
|
const wikiLink = schema.marks.wikiLink.create({ title: 'Target note', aliased: true });
|
||||||
|
const serialized = serializeInlineMarkdown(
|
||||||
|
paragraph([
|
||||||
|
{ text: 'See ', marks: [bold] },
|
||||||
|
{ text: 'this note', marks: [wikiLink, bold] },
|
||||||
|
{ text: ' now', marks: [bold] }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(serialized, '**See [[Target note|this note]] now**');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user