Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions plugins/clipboard/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Changelog

## 1.4.0 - 2026-08-31

- 新增剪贴板文本掩码/明文显示切换。
- 掩码仅隐藏中间内容,保留首尾识别字符。
- 掩码仅影响界面展示,复制和粘贴仍使用原始明文。

1 change: 1 addition & 0 deletions plugins/clipboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- **实时监听** - 自动监听系统剪贴板变化,实时同步最新内容
- **分页加载** - 支持无限滚动,自动加载更多历史记录
- **多选合并** - 支持批量合并文字、图片和文件后一次复制或粘贴
- **掩码显示** - 可一键隐藏文本记录的中间内容并保留首尾识别字符,复制和粘贴仍使用原始明文

### 🔍 高效查找

Expand Down
2 changes: 1 addition & 1 deletion plugins/clipboard/public/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "clipboard",
"description": "剪贴板",
"version": "1.3.2",
"version": "1.4.0",
"main": "index.html",
"preload": "preload.js",
"logo": "logo.png",
Expand Down
27 changes: 27 additions & 0 deletions plugins/clipboard/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ import ConfirmDialog from '@/components/ConfirmDialog.vue'
// ---- 状态 ----
const activeTab = ref('all')
const searchText = ref('')
const TEXT_MASK_STORAGE_KEY = 'clipboard.isTextMasked'

const getInitialTextMaskState = () => {
try {
return window.localStorage.getItem(TEXT_MASK_STORAGE_KEY) === 'true'
} catch (error) {
console.warn('读取掩码显示设置失败:', error)
return false
}
}

const isTextMasked = ref(getInitialTextMaskState())

const toggleTextMask = () => {
isTextMasked.value = !isTextMasked.value
}

const getCurrentAppVersion = () => {
try {
Expand Down Expand Up @@ -258,6 +274,14 @@ const resetSearchAndFocus = async () => {

// ---- 监听 & 生命周期 ----
watch(activeTab, doReload)
watch(isTextMasked, (masked) => {
try {
window.localStorage.setItem(TEXT_MASK_STORAGE_KEY, String(masked))
} catch (error) {
console.warn('保存掩码显示设置失败:', error)
}
checkTextOverflow()
})

// 当对话框打开时,阻止全局键盘快捷键(如 Enter 粘贴)
const isAnyModalOpen = computed(() =>
Expand Down Expand Up @@ -325,6 +349,7 @@ onUnmounted(() => {
:active-tab="activeTab"
:expanded-items="expandedItems"
:needs-expand="needsExpand"
:is-text-masked="isTextMasked"
@select="handleItemClick"
@toggle-selection="handleToggleClick"
@dblclick="handleDoubleClick"
Expand All @@ -338,8 +363,10 @@ onUnmounted(() => {

<SideBar
:selected-count="selectedCount"
:is-text-masked="isTextMasked"
@copy="copySelected"
@paste="pasteSelected"
@toggle-text-mask="toggleTextMask"
@clear="handleClearClick"
/>

Expand Down
4 changes: 3 additions & 1 deletion plugins/clipboard/src/components/ClipboardList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ const props = defineProps({
selectedItems: { type: Set, default: () => new Set() },
activeTab: { type: String, required: true },
expandedItems: { type: Set, default: () => new Set() },
needsExpand: { type: Object, default: () => ({}) }
needsExpand: { type: Object, default: () => ({}) },
isTextMasked: { type: Boolean, default: false }
})

const emit = defineEmits([
Expand Down Expand Up @@ -254,6 +255,7 @@ onBeforeUnmount(resetDragState)
:is-expanded="expandedItems.has(item.id)"
:needs-expand="!!needsExpand[item.id]"
:is-favorite-tab="activeTab === 'favorite'"
:is-text-masked="isTextMasked"
@toggle-expand="emit('toggle-expand', item.id)"
@delete-favorite="emit('delete-favorite', index)"
/>
Expand Down
32 changes: 30 additions & 2 deletions plugins/clipboard/src/components/SideBar.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<script setup>
defineProps({
selectedCount: { type: Number, default: 0 }
selectedCount: { type: Number, default: 0 },
isTextMasked: { type: Boolean, default: false }
})

const emit = defineEmits(['copy', 'paste', 'clear'])
const emit = defineEmits(['copy', 'paste', 'toggle-text-mask', 'clear'])
</script>

<template>
Expand Down Expand Up @@ -36,6 +37,27 @@ const emit = defineEmits(['copy', 'paste', 'clear'])
</svg>
<span v-if="selectedCount > 1" class="selection-count">{{ selectedCount }}</span>
</button>

<!-- 文本掩码显示切换 -->
<button
class="sidebar-btn mask-btn"
:class="{ active: isTextMasked }"
type="button"
:aria-pressed="isTextMasked"
:aria-label="isTextMasked ? '切换为明文显示' : '切换为掩码显示'"
:data-tooltip="isTextMasked ? '切换为明文显示' : '切换为掩码显示'"
@click="emit('toggle-text-mask')"
>
<svg v-if="isTextMasked" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M3 3l18 18M10.6 10.7a2 2 0 0 0 2.7 2.7M9.9 4.2A10.8 10.8 0 0 1 12 4c5.5 0 9 5.5 9 8a10.8 10.8 0 0 1-2.1 3.5M6.6 6.7C4.4 8.1 3 10.4 3 12c0 2.5 3.5 8 9 8 1.4 0 2.7-.4 3.8-1"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<svg v-else viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M3 12c0-2.5 3.5-8 9-8s9 5.5 9 8-3.5 8-9 8-9-5.5-9-8z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
</svg>
</button>
</div>

<div class="sidebar-bottom">
Expand Down Expand Up @@ -168,6 +190,12 @@ const emit = defineEmits(['copy', 'paste', 'clear'])
border-color: #4caf50;
}

.sidebar-btn.mask-btn.active {
color: var(--primary-color);
border-color: var(--primary-color);
background: var(--bg-accent-light);
}

.sidebar-btn.clear-btn {
color: var(--text-danger);
}
Expand Down
14 changes: 11 additions & 3 deletions plugins/clipboard/src/components/items/TextItem.vue
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
<script setup>
defineProps({
import { computed } from 'vue'
import { maskTextContent } from '@/utils/textMask'

const props = defineProps({
item: { type: Object, required: true },
isExpanded: { type: Boolean, default: false },
needsExpand: { type: Boolean, default: false },
isFavoriteTab: { type: Boolean, default: false }
isFavoriteTab: { type: Boolean, default: false },
isTextMasked: { type: Boolean, default: false }
})

const emit = defineEmits(['toggle-expand', 'delete-favorite'])

const displayedContent = computed(() =>
props.isTextMasked ? maskTextContent(props.item.content) : props.item.content
)
</script>

<template>
Expand All @@ -19,7 +27,7 @@ const emit = defineEmits(['toggle-expand', 'delete-favorite'])
'text-expanded': isExpanded
}"
>
{{ item.content }}
{{ displayedContent }}
</div>
<div class="item-meta">
<span class="meta-time">{{ item.time }}</span>
Expand Down
34 changes: 34 additions & 0 deletions plugins/clipboard/src/utils/textMask.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const MASK_CHARACTER = '•'
const EDGE_VISIBLE_COUNT = 3

const getVisibleEdgeCount = (visibleCharacterCount) => {
if (visibleCharacterCount <= 2) return 0
if (visibleCharacterCount <= EDGE_VISIBLE_COUNT * 2) return 1
return EDGE_VISIBLE_COUNT
}

/**
* Mask the middle of a string while retaining a recognizable prefix and suffix.
* Whitespace and line breaks keep their original positions.
* The original clipboard content is never modified.
*/
export const maskTextContent = (content) => {
const characters = Array.from(String(content ?? ''))
const visibleCharacterCount = characters.reduce(
(count, character) => count + (/\S/u.test(character) ? 1 : 0),
0
)
const visibleEdgeCount = getVisibleEdgeCount(visibleCharacterCount)
let visibleIndex = 0

return characters.map(character => {
if (/\s/u.test(character)) return character

const shouldRemainVisible = visibleEdgeCount > 0 && (
visibleIndex < visibleEdgeCount ||
visibleIndex >= visibleCharacterCount - visibleEdgeCount
)
visibleIndex++
return shouldRemainVisible ? character : MASK_CHARACTER
}).join('')
}
27 changes: 27 additions & 0 deletions plugins/clipboard/tests/textMask.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { maskTextContent } from '../src/utils/textMask.js'

test('keeps three visible characters at both ends of longer content', () => {
assert.equal(maskTextContent('P@ssw0rd-178452'), `P@s${'•'.repeat(9)}452`)
})

test('keeps one visible character at both ends of short content', () => {
assert.equal(maskTextContent('178452'), '1••••2')
assert.equal(maskTextContent('国际化'), '国•化')
})

test('preserves whitespace layout while counting only visible characters', () => {
assert.equal(maskTextContent('abc def\nxyz'), 'abc •••\nxyz')
})

test('masks very short content completely and handles Unicode code points', () => {
assert.equal(maskTextContent('🔐🙂'), '••')
assert.equal(maskTextContent('A'), '•')
})

test('handles empty values without exposing content', () => {
assert.equal(maskTextContent(''), '')
assert.equal(maskTextContent(null), '')
assert.equal(maskTextContent(undefined), '')
})