diff --git a/main.ts b/main.ts index b155e29..46306c4 100644 --- a/main.ts +++ b/main.ts @@ -243,6 +243,9 @@ export default class GridExplorerPlugin extends Plugin { // 只處理左鍵 if (evt.button !== 0) return; + // 若點擊的是屬性面板中 tag pill 的刪除按鈕,直接跳過 + if ((evt.target as HTMLElement).closest('.multi-select-pill-remove-button')) return; + // 從觸發點往上找,看是否碰到 tag 連結 const el = (evt.target as HTMLElement).closest( 'a.tag, /* 預覽模式中的 #tag */\n' + @@ -296,6 +299,88 @@ export default class GridExplorerPlugin extends Plugin { view.render(true); // resetScroll } }, true); // 用 capture,可在其他 listener 前先吃到 + + this.setupCanvasDropHandlers(); + } + + private setupCanvasDropHandlers() { + const setup = () => { + this.app.workspace.getLeavesOfType('canvas').forEach(leaf => { + const canvasView = leaf.view as any; + if (canvasView.gridExplorerDropHandler) { + return; + } + canvasView.gridExplorerDropHandler = true; + + const canvasEl = canvasView.containerEl; + + const dragoverHandler = (evt: DragEvent) => { + // 只處理來自 Grid Explorer 的檔案拖曳,以顯示正確的游標 + if (evt.dataTransfer?.types.includes('application/obsidian-grid-explorer-files')) { + evt.preventDefault(); + evt.dataTransfer.dropEffect = 'copy'; + } + }; + + const dropHandler = async (evt: DragEvent) => { + // 只處理來自 Grid Explorer 的拖曳事件 + if (!evt.dataTransfer?.types.includes('application/obsidian-grid-explorer-files')) { + return; // 如果不是,則不做任何事,讓事件繼續傳遞給 Canvas 的預設處理器 + } + + // 來自 Grid Explorer 的拖曳,處理它並阻止預設行為 + evt.preventDefault(); + evt.stopPropagation(); + + let filePath: string | undefined; + + const data = evt.dataTransfer.getData('application/obsidian-grid-explorer-files'); + try { + const paths = JSON.parse(data); + // 目前我們只支援單一檔案拖放 + if (Array.isArray(paths) && paths.length === 1) { + filePath = paths[0]; + } + } catch (e) { + console.error("Grid Explorer: Failed to parse drop data from Grid Explorer.", e); + } + + // 如果無法取得檔案路徑,則中止操作 + if (!filePath) { + return; + } + + const tfile = this.app.vault.getAbstractFileByPath(filePath); + if (!(tfile instanceof TFile)) { + console.warn('Grid Explorer: Dropped item is not a TFile or could not be found.', filePath); + return; + } + + const canvas = canvasView.canvas; + if (!canvas) { + return; + } + + const pos = canvas.posFromEvt(evt); + + const newNode = canvas.createFileNode({ + file: tfile, + pos: pos, + size: { width: 400, height: 400 }, // 預設大小 + focus: true, // 建立後自動對焦 + }); + + canvas.addNode(newNode); + await canvas.requestSave(); + }; + + this.registerDomEvent(canvasEl, 'dragover', dragoverHandler); + this.registerDomEvent(canvasEl, 'drop', dropHandler, true); // 使用捕獲階段,以優先處理事件 + }); + }; + + this.registerEvent(this.app.workspace.on('layout-change', setup)); + setup(); // 首次執行設定 } async openNoteInRecentFiles(file: TFile) { diff --git a/manifest.json b/manifest.json index 116fe70..27ce315 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "gridexplorer", "name": "GridExplorer", - "version": "2.4.6", + "version": "2.4.7", "minAppVersion": "1.1.0", "description": "Browse note files in a grid view.", "author": "Devon22", diff --git a/package-lock.json b/package-lock.json index 34e839a..a4db6a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gridexplorer", - "version": "2.4.6", + "version": "2.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gridexplorer", - "version": "2.4.6", + "version": "2.4.7", "license": "MIT", "devDependencies": { "@types/node": "^16.11.6", diff --git a/package.json b/package.json index aed2f91..dc15f72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gridexplorer", - "version": "2.4.6", + "version": "2.4.7", "description": "Browse note files in a grid view.", "main": "main.js", "scripts": { diff --git a/src/GridView.ts b/src/GridView.ts index 64727f0..4706287 100644 --- a/src/GridView.ts +++ b/src/GridView.ts @@ -196,30 +196,8 @@ export class GridView extends ItemView { // 添加新增筆記按鈕 const newNoteButton = headerButtonsDiv.createEl('button', { attr: { 'aria-label': t('new_note') } }); - newNoteButton.addEventListener('click', async () => { - let newFileName = `${t('untitled')}.md`; - let newFilePath = !this.sourcePath || this.sourcePath === '/' ? newFileName : `${this.sourcePath}/${newFileName}`; - - // 檢查檔案是否已存在,如果存在則遞增編號 - let counter = 1; - while (this.app.vault.getAbstractFileByPath(newFilePath)) { - newFileName = `${t('untitled')} ${counter}.md`; - newFilePath = !this.sourcePath || this.sourcePath === '/' ? newFileName : `${this.sourcePath}/${newFileName}`; - counter++; - } - - try { - // 建立新筆記 - const newFile = await this.app.vault.create(newFilePath, ''); - // 開啟新筆記 - await this.app.workspace.getLeaf().openFile(newFile); - } catch (error) { - console.error('An error occurred while creating a new note:', error); - } - }); setIcon(newNoteButton, 'square-pen'); - - newNoteButton.addEventListener('contextmenu', (event) => { + newNoteButton.addEventListener('click', (event) => { event.preventDefault(); const menu = new Menu(); // 新增筆記 @@ -274,6 +252,32 @@ export class GridView extends ItemView { } }); }); + // 新增畫布 + menu.addItem((item) => { + item.setTitle(t('new_canvas')) + .setIcon('layout-dashboard') + .onClick(async () => { + let newFileName = `${t('untitled')}.canvas`; + let newFilePath = !this.sourcePath || this.sourcePath === '/' ? newFileName : `${this.sourcePath}/${newFileName}`; + + // 檢查檔案是否已存在,如果存在則遞增編號 + let counter = 1; + while (this.app.vault.getAbstractFileByPath(newFilePath)) { + newFileName = `${t('untitled')} ${counter}.canvas`; + newFilePath = !this.sourcePath || this.sourcePath === '/' ? newFileName : `${this.sourcePath}/${newFileName}`; + counter++; + } + + try { + // 建立新筆記 + const newFile = await this.app.vault.create(newFilePath, ''); + // 開啟新筆記 + await this.app.workspace.getLeaf().openFile(newFile); + } catch (error) { + console.error('An error occurred while creating a new canvas:', error); + } + }); + }); menu.showAtMouseEvent(event); }); @@ -1318,52 +1322,39 @@ export class GridView extends ItemView { const fileCache = this.app.metadataCache.getFileCache(file); let matchesTags = false; - if (fileCache && fileCache.tags && Array.isArray(fileCache.tags)) { - // 檢查內文中的標籤 - const tags = fileCache.tags; - matchesTags = tagTerms.every(tag => - tags.some((t: any) => t && t.tag && t.tag.toLowerCase() === '#' + tag) - ); - } - - // 如果前面的標籤檢查沒有匹配,檢查 frontmatter 中的標籤 - if (!matchesTags && fileCache && fileCache.frontmatter && fileCache.frontmatter.tags) { - let frontmatterTags = fileCache.frontmatter.tags; - if (typeof frontmatterTags === 'string') { - // 如果是字串,分割成陣列 - const tagArray = frontmatterTags.split(/[,\s]+/).filter(t => t.trim() !== ''); - matchesTags = tagTerms.every(tag => - tagArray.some(t => { - // 去除可能的 # 符號再比較 - const cleanTag = t.toLowerCase().replace("#", ''); - - // 直接比較 - if (cleanTag === tag) return true; - - // 如果是空格分隔的多個標籤,分割並檢查 - const subTags = cleanTag.split(/\s+/).filter(st => st.trim() !== ''); - return subTags.some(st => st === tag); - }) - ); - } else if (Array.isArray(frontmatterTags)) { - // 如果是陣列,直接檢查 - matchesTags = tagTerms.every(tag => - frontmatterTags.some(t => { - if (typeof t === 'string') { - // 去除可能的 # 符號再比較 - const cleanTag = t.toLowerCase().replace("#", ''); - - // 直接比較 - if (cleanTag === tag) return true; - - // 如果是空格分隔的多個標籤,分割並檢查 - const subTags = cleanTag.split(/\s+/).filter(st => st.trim() !== ''); - return subTags.some(st => st === tag); - } - return false; - }) - ); + if (fileCache) { + const collectedTags: string[] = []; + + // 內文標籤 + if (Array.isArray(fileCache.tags)) { + for (const t of fileCache.tags) { + if (t && t.tag) { + const clean = t.tag.toLowerCase().replace(/^#/, ''); + collectedTags.push(...clean.split(/\s+/).filter(st => st.trim() !== '')); + } + } } + + // frontmatter 標籤 + if (fileCache.frontmatter && fileCache.frontmatter.tags) { + const fmTags = fileCache.frontmatter.tags; + if (typeof fmTags === 'string') { + collectedTags.push( + ...fmTags.split(/[,\s]+/) + .map(t => t.toLowerCase().replace(/^#/, '')) + .filter(t => t.trim() !== '') + ); + } else if (Array.isArray(fmTags)) { + for (const t of fmTags) { + if (typeof t === 'string') { + const clean = t.toLowerCase().replace(/^#/, ''); + collectedTags.push(...clean.split(/\s+/).filter(st => st.trim() !== '')); + } + } + } + } + + matchesTags = tagTerms.every(tag => collectedTags.includes(tag)); } // 如果標籤匹配,且檔名或內容也匹配(如果有一般搜尋詞的話),則加入結果 diff --git a/src/SearchModal.ts b/src/SearchModal.ts index b0ee4b4..b7232a4 100644 --- a/src/SearchModal.ts +++ b/src/SearchModal.ts @@ -20,20 +20,33 @@ export class SearchModal extends Modal { // 創建搜尋輸入框容器 const searchContainer = contentEl.createDiv('ge-search-container'); + // 創建搜尋輸入框容器 + const searchInputWrapper = searchContainer.createDiv('ge-search-input-wrapper'); + + // 創建標籤顯示區域 + const tagDisplayArea = searchInputWrapper.createDiv('ge-search-tag-display-area'); + // 創建搜尋輸入框 - const searchInput = searchContainer.createEl('input', { + const searchInput = searchInputWrapper.createEl('input', { type: 'text', value: this.defaultQuery, - placeholder: t('search_placeholder') + placeholder: t('search_placeholder'), + cls: 'ge-search-input' }); + // 創建輸入框容器包裝層 + const inputContainer = searchInputWrapper.createDiv('ge-input-container'); + + // 將輸入框移動到容器中 + inputContainer.appendChild(searchInput); + // 創建清空按鈕 - const clearButton = searchContainer.createDiv('ge-search-clear-button'); //這裡不是用 ge-clear-button + const clearButton = inputContainer.createDiv('ge-search-clear-button'); //這裡不是用 ge-clear-button clearButton.style.display = this.defaultQuery ? 'flex' : 'none'; setIcon(clearButton, 'x'); // 建立標籤建議容器 - const tagSuggestionContainer = contentEl.createDiv('ge-tag-suggestions'); + const tagSuggestionContainer = contentEl.createDiv('ge-search-tag-suggestions'); tagSuggestionContainer.style.display = 'none'; // 取得並快取所有標籤 (移除 # 前綴) @@ -61,7 +74,7 @@ export class SearchModal extends Modal { tagSuggestionContainer.empty(); tagSuggestions.forEach((tag, idx) => { - const item = tagSuggestionContainer.createDiv('ge-tag-suggestion-item'); + const item = tagSuggestionContainer.createDiv('ge-search-tag-suggestion-item'); item.textContent = `#${tag}`; if (idx === selectedSuggestionIndex) item.addClass('is-selected'); item.addEventListener('mousedown', (e) => { @@ -74,23 +87,28 @@ export class SearchModal extends Modal { const applySuggestion = (index: number) => { if (index < 0 || index >= tagSuggestions.length) return; - const value = searchInput.value; + const value = searchInput.value.trim(); const cursor = searchInput.selectionStart || 0; const beforeMatch = value.substring(0, cursor).replace(/#([^#\\s]*)$/, `#${tagSuggestions[index]} `); const afterCursor = value.substring(cursor); searchInput.value = beforeMatch + afterCursor; + searchInput.value = searchInput.value.trim(); const newCursorPos = beforeMatch.length; searchInput.setSelectionRange(newCursorPos, newCursorPos); tagSuggestionContainer.style.display = 'none'; tagSuggestionContainer.empty(); selectedSuggestionIndex = -1; clearButton.style.display = searchInput.value ? 'flex' : 'none'; + + // 更新標籤按鈕顯示 + renderTagButtons(); }; // 監聽輸入框變化來控制清空按鈕的顯示並更新標籤建議 searchInput.addEventListener('input', () => { clearButton.style.display = searchInput.value ? 'flex' : 'none'; updateTagSuggestions(); + renderTagButtons(); }); // 處理上下鍵及 Enter 選擇建議 @@ -116,6 +134,8 @@ export class SearchModal extends Modal { clearButton.addEventListener('click', () => { searchInput.value = ''; clearButton.style.display = 'none'; + tagDisplayArea.empty(); + tagDisplayArea.style.display = 'none'; searchInput.focus(); }); @@ -192,6 +212,86 @@ export class SearchModal extends Modal { text: t('cancel') }); + // 解析輸入內容並渲染成按鈕 + const renderTagButtons = () => { + // 清空現有標籤顯示區域 + tagDisplayArea.empty(); + + // 獲取輸入值 + const inputValue = searchInput.value.trim(); + + // 如果輸入為空,隱藏標籤顯示區域 + if (!inputValue) { + tagDisplayArea.style.display = 'none'; + return; + } + + // 使用空格分割輸入內容 + const terms = inputValue.split(/\s+/); + + // 如果沒有分割出任何詞彙,隱藏標籤顯示區域 + if (terms.length === 0) { + tagDisplayArea.style.display = 'none'; + return; + } + + // 顯示標籤顯示區域 + tagDisplayArea.style.display = 'flex'; + + // 分析輸入內容中各詞彙的位置 + let currentIndex = 0; + const termPositions: {term: string, startIndex: number, endIndex: number}[] = []; + + terms.forEach(term => { + if (!term) return; // 跳過空詞彙 + + // 尋找該詞彙在原始輸入中的位置 + const startIndex = inputValue.indexOf(term, currentIndex); + if (startIndex === -1) return; // 如果找不到,跳過 + + const endIndex = startIndex + term.length; + + termPositions.push({ + term: term, + startIndex: startIndex, + endIndex: endIndex + }); + + currentIndex = endIndex; + }); + + // 為每個詞彙創建按鈕 + termPositions.forEach(termInfo => { + const tagButton = tagDisplayArea.createDiv('ge-search-tag-button'); + tagButton.textContent = termInfo.term; + + // 判斷是否為標籤,如果是則添加特殊樣式 + if (termInfo.term.startsWith('#')) { + tagButton.addClass('is-tag'); + } + + // 創建刪除按鈕 + const deleteButton = tagButton.createDiv('ge-search-tag-delete-button'); + setIcon(deleteButton, 'x'); + + // 點擊刪除按鈕時從輸入框中移除該詞彙 + deleteButton.addEventListener('click', (e) => { + e.stopPropagation(); + const newValue = + inputValue.substring(0, termInfo.startIndex) + + inputValue.substring(termInfo.endIndex); + searchInput.value = newValue.trim(); + + // 觸發輸入事件以更新UI + const inputEvent = new Event('input', { bubbles: true }); + searchInput.dispatchEvent(inputEvent); + + // 聚焦回輸入框 + searchInput.focus(); + }); + }); + }; + // 綁定搜尋事件 const performSearch = () => { this.gridView.searchQuery = searchInput.value; @@ -215,6 +315,9 @@ export class SearchModal extends Modal { this.close(); }); + // 初始渲染標籤按鈕 + renderTagButtons(); + // 自動聚焦到搜尋輸入框,並將游標移到最後 searchInput.focus(); searchInput.setSelectionRange(searchInput.value.length, searchInput.value.length); diff --git a/src/translations.ts b/src/translations.ts index c9c2445..7121861 100644 --- a/src/translations.ts +++ b/src/translations.ts @@ -38,6 +38,7 @@ export const TRANSLATIONS: Translations = { 'cancel': '取消', 'new_note': '新增筆記', 'new_folder': '新增資料夾', + 'new_canvas': '新增畫布', 'delete_folder': '刪除資料夾', 'untitled': '未命名', 'files': '個檔案', @@ -250,6 +251,7 @@ export const TRANSLATIONS: Translations = { 'cancel': 'Cancel', 'new_note': 'New note', 'new_folder': 'New folder', + 'new_canvas': 'New canvas', 'delete_folder': 'Delete folder', 'untitled': 'Untitled', 'files': 'files', @@ -462,6 +464,7 @@ export const TRANSLATIONS: Translations = { 'cancel': '取消', 'new_note': '新建笔记', 'new_folder': '新建文件夹', + 'new_canvas': '新建画布', 'delete_folder': '删除文件夹', 'untitled': '未命名', 'files': '个文件', @@ -674,6 +677,7 @@ export const TRANSLATIONS: Translations = { 'cancel': 'キャンセル', 'new_note': '新規ノート', 'new_folder': '新規フォルダ', + 'new_canvas': '新規キャンバス', 'delete_folder': '削除フォルダ', 'untitled': '無題', 'files': 'ファイル', @@ -886,6 +890,7 @@ export const TRANSLATIONS: Translations = { 'cancel': 'Отмена', 'new_note': 'Новая заметка', 'new_folder': 'Новая папка', + 'new_canvas': 'Новый канвас', 'delete_folder': 'Удалить папку', 'untitled': 'Без названия', 'files': 'файлы', @@ -1098,6 +1103,7 @@ export const TRANSLATIONS: Translations = { 'cancel': 'Скасувати', 'new_note': 'Нова нотатка', 'new_folder': 'Нова папка', + 'new_canvas': 'Новий канвас', 'delete_folder': 'Видалити папку', 'untitled': 'Без назви', 'files': 'файли', diff --git a/styles.css b/styles.css index 8d17e5b..1da86ad 100644 --- a/styles.css +++ b/styles.css @@ -377,6 +377,125 @@ align-items: center; } +.ge-search-input-wrapper { + position: relative; + flex: 1; + display: flex; + flex-direction: column; +} + +.ge-input-container { + position: relative; + width: 100%; + display: flex; + align-items: center; +} + +.ge-search-clear-button { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + color: var(--text-muted); + cursor: pointer; + z-index: 10; +} + +.ge-search-clear-button:hover { + color: var(--text-accent); +} + +.ge-search-clear-button svg { + width: 16px; + height: 16px; +} + +/* 標籤顯示區域 */ +.ge-search-tag-display-area { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 5px; + margin-bottom: 10px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background-color: var(--background-primary); + align-items: center; +} + +.ge-search-tag-button { + display: inline-flex; + align-items: center; + padding: 3px 7px; + background-color: var(--background-modifier-active-hover); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; + padding-right: 24px; + height: 24px; + border: none; +} + +.ge-search-tag-delete-button { + position: absolute; + right: 2px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background-color: transparent; + cursor: pointer; + transition: background-color 0.2s; + opacity: 0.8; +} + +.ge-search-tag-delete-button:hover { + background-color: rgba(255, 255, 255, 0.2); + opacity: 1; +} + +.ge-search-tag-delete-button svg { + width: 22px; + height: 22px; +} + +/* 標籤自動完成下拉選單 */ +.ge-search-tag-suggestions { + position: relative; + margin-top: 4px; + background-color: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + max-height: 150px; + overflow-y: auto; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + z-index: 1000; +} + +.ge-search-tag-suggestion-item { + padding: 4px 8px; + cursor: pointer; + white-space: nowrap; +} + +.ge-search-tag-suggestion-item:hover, +.ge-search-tag-suggestion-item.is-selected { + background-color: var(--interactive-accent); + color: var(--text-on-accent, white); +} + .ge-search-container input { width: 100%; padding: 8px 12px; @@ -947,27 +1066,3 @@ width: 60px; } -/* 標籤自動完成下拉選單 */ -.ge-tag-suggestions { - position: relative; - margin-top: 4px; - background-color: var(--background-primary); - border: 1px solid var(--background-modifier-border); - border-radius: 4px; - max-height: 150px; - overflow-y: auto; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); - z-index: 1000; -} - -.ge-tag-suggestion-item { - padding: 4px 8px; - cursor: pointer; - white-space: nowrap; -} - -.ge-tag-suggestion-item:hover, -.ge-tag-suggestion-item.is-selected { - background-color: var(--interactive-accent); - color: var(--text-on-accent, white); -} \ No newline at end of file diff --git a/versions.json b/versions.json index f020afa..c88f72a 100644 --- a/versions.json +++ b/versions.json @@ -1,3 +1,3 @@ { - "2.4.6": "1.1.0" + "2.4.7": "1.1.0" } \ No newline at end of file