427 lines
15 KiB
QML
427 lines
15 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Layouts
|
|
import Quickshell
|
|
import qs.Commons
|
|
import qs.Services.Keyboard
|
|
import qs.Services.Noctalia
|
|
import qs.Services.UI
|
|
import qs.Widgets
|
|
|
|
Rectangle {
|
|
id: root
|
|
focus: true
|
|
property var clipboardItem: null
|
|
property var pluginApi: null
|
|
property var screen: null
|
|
property var panelRoot: null
|
|
property string clipboardId: clipboardItem ? clipboardItem.id : ""
|
|
property string mime: clipboardItem ? clipboardItem.mime : ""
|
|
property string preview: clipboardItem ? clipboardItem.preview : ""
|
|
property string pinnedImageDataUrl: "" // For pinned images - data URL
|
|
|
|
// Content type detection
|
|
readonly property bool isImage: clipboardItem && clipboardItem.isImage
|
|
readonly property bool isColor: {
|
|
if (isImage || !preview)
|
|
return false;
|
|
const trimmed = preview.trim();
|
|
return /^#[A-Fa-f0-9]{6}$/.test(trimmed) || /^#[A-Fa-f0-9]{3}$/.test(trimmed) || /^[A-Fa-f0-9]{6}$/.test(trimmed) || /^rgba?\(.*\)$/i.test(trimmed);
|
|
}
|
|
readonly property bool isLink: !isImage && !isColor && preview && /^https?:\/\//.test(preview.trim())
|
|
readonly property bool isCode: !isImage && !isColor && !isLink && preview && (preview.includes("function") || preview.includes("import ") || preview.includes("const ") || preview.includes("let ") || preview.includes("var ") || preview.includes("class ") || preview.includes("def ") || preview.includes("return ") || /^[\{\[\(<]/.test(preview.trim()))
|
|
readonly property bool isEmoji: {
|
|
if (isImage || isColor || isLink || isCode || !preview)
|
|
return false;
|
|
const trimmed = preview.trim();
|
|
return trimmed.length <= 4 && trimmed.charCodeAt(0) > 255;
|
|
}
|
|
readonly property bool isFile: !isImage && !isColor && !isLink && !isCode && !isEmoji && preview && /^(\/|~|file:\/\/)/.test(preview.trim())
|
|
readonly property bool isText: !isImage && !isColor && !isLink && !isCode && !isEmoji && !isFile
|
|
|
|
// Helper to safely access Color singleton
|
|
function getColor(propName, fallback) {
|
|
if (typeof Color !== "undefined" && Color[propName])
|
|
return Color[propName];
|
|
return fallback;
|
|
}
|
|
|
|
readonly property string colorValue: {
|
|
if (!isColor || !preview)
|
|
return "";
|
|
const trimmed = preview.trim();
|
|
if (/^#[A-Fa-f0-9]{3,6}$/.test(trimmed))
|
|
return trimmed;
|
|
if (/^[A-Fa-f0-9]{6}$/.test(trimmed))
|
|
return "#" + trimmed;
|
|
return trimmed;
|
|
}
|
|
|
|
readonly property string typeLabel: isImage ? "Image" : isColor ? "Color" : isLink ? "Link" : isCode ? "Code" : isEmoji ? "Emoji" : isFile ? "File" : "Text"
|
|
readonly property string typeIcon: isImage ? "photo" : isColor ? "palette" : isLink ? "link" : isCode ? "code" : isEmoji ? "mood-smile" : isFile ? "file" : "align-left"
|
|
|
|
// Default colors per card type
|
|
readonly property var defaultCardColors: {
|
|
"Text": {
|
|
bg: "mOutline",
|
|
separator: "mSurface",
|
|
fg: "mOnSurface"
|
|
},
|
|
"Image": {
|
|
bg: "mTertiary",
|
|
separator: "mSurface",
|
|
fg: "mOnTertiary"
|
|
},
|
|
"Link": {
|
|
bg: "mPrimary",
|
|
separator: "mSurface",
|
|
fg: "mOnPrimary"
|
|
},
|
|
"Code": {
|
|
bg: "mSecondary",
|
|
separator: "mSurface",
|
|
fg: "mOnSecondary"
|
|
},
|
|
"Color": {
|
|
bg: "mSecondary",
|
|
separator: "mSurface",
|
|
fg: "mOnSecondary"
|
|
},
|
|
"Emoji": {
|
|
bg: "mHover",
|
|
separator: "mSurface",
|
|
fg: "mOnHover"
|
|
},
|
|
"File": {
|
|
bg: "mError",
|
|
separator: "mSurface",
|
|
fg: "mOnError"
|
|
}
|
|
}
|
|
|
|
// Get color setting for current card type
|
|
function getCardColorSetting(colorType) {
|
|
const settings = pluginApi?.pluginSettings?.cardColors;
|
|
const customColors = pluginApi?.pluginSettings?.customColors;
|
|
const cardType = typeLabel;
|
|
|
|
if (settings && settings[cardType] && settings[cardType][colorType]) {
|
|
const colorKey = settings[cardType][colorType];
|
|
if (colorKey === "custom" && customColors && customColors[cardType]) {
|
|
return customColors[cardType][colorType] || "#888888";
|
|
}
|
|
return getColor(colorKey, defaultCardColors[cardType]?.[colorType] || "#888888");
|
|
}
|
|
|
|
// Fallback to default
|
|
const defaultKey = defaultCardColors[cardType]?.[colorType];
|
|
return getColor(defaultKey, "#888888");
|
|
}
|
|
|
|
// Colors from settings or defaults
|
|
readonly property color accentColor: getCardColorSetting("bg")
|
|
readonly property color accentFgColor: getCardColorSetting("fg")
|
|
readonly property color separatorColor: getCardColorSetting("separator")
|
|
|
|
signal clicked
|
|
signal deleteClicked
|
|
signal addToTodoClicked
|
|
signal pinClicked
|
|
signal rightClicked
|
|
property bool selected: false
|
|
property bool enableTodoIntegration: false
|
|
property bool isPinned: false
|
|
|
|
width: 250
|
|
height: parent.height
|
|
|
|
// Get ToDo pages from plugin
|
|
function getTodoPages() {
|
|
const todoApi = PluginService.getPluginAPI("todo");
|
|
if (!todoApi || !todoApi.pluginSettings || !todoApi.pluginSettings.pages) {
|
|
return [
|
|
{
|
|
id: 0,
|
|
name: "General"
|
|
}
|
|
];
|
|
}
|
|
return todoApi.pluginSettings.pages;
|
|
}
|
|
|
|
// Build menu model from ToDo pages
|
|
function buildTodoMenuModel() {
|
|
const pages = getTodoPages();
|
|
const model = [];
|
|
for (let i = 0; i < pages.length; i++) {
|
|
model.push({
|
|
"label": pages[i].name,
|
|
"action": "page-" + pages[i].id,
|
|
"icon": "checkbox"
|
|
});
|
|
}
|
|
return model;
|
|
}
|
|
|
|
// Anchor point for context menu positioning
|
|
Item {
|
|
id: menuAnchor
|
|
width: 0
|
|
height: 0
|
|
visible: false
|
|
}
|
|
|
|
// Context menu for ToDo page selection
|
|
NPopupContextMenu {
|
|
id: todoContextMenu
|
|
screen: root.screen
|
|
anchorItem: menuAnchor
|
|
|
|
onTriggered: action => {
|
|
todoContextMenu.visible = false;
|
|
|
|
if (action.startsWith("page-")) {
|
|
const pageId = parseInt(action.replace("page-", ""));
|
|
if (root.preview) {
|
|
root.pluginApi?.mainInstance?.addTodoWithText(root.preview.substring(0, 200), pageId);
|
|
}
|
|
}
|
|
}
|
|
|
|
onVisibleChanged: {
|
|
if (!visible) {
|
|
// Cleanup when menu closes
|
|
root.focus = true;
|
|
if (root.panelRoot && root.panelRoot.activeContextMenu === todoContextMenu) {
|
|
root.panelRoot.activeContextMenu = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Click outside to close menu
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
enabled: todoContextMenu.visible
|
|
z: todoContextMenu.visible ? 999 : -1
|
|
onClicked: {
|
|
todoContextMenu.visible = false;
|
|
}
|
|
}
|
|
|
|
// Body background - Same as accent color
|
|
color: selected ? Qt.darker(accentColor, 1.1) : (mouseArea.containsMouse ? Qt.lighter(accentColor, 1.1) : accentColor)
|
|
|
|
radius: (typeof Style !== "undefined") ? Style.radiusM : 16
|
|
border.width: 2
|
|
border.color: accentColor // Border same as background
|
|
|
|
// Visual indicator for pinned status (small icon in corner)
|
|
NIcon {
|
|
visible: root.isPinned
|
|
anchors.top: parent.top
|
|
anchors.right: parent.right
|
|
anchors.margins: 4
|
|
z: 100
|
|
icon: "pin-filled"
|
|
pointSize: 10
|
|
color: root.accentFgColor
|
|
opacity: 0.6
|
|
}
|
|
|
|
ColumnLayout {
|
|
anchors.fill: parent
|
|
spacing: 0
|
|
|
|
Rectangle {
|
|
id: headerBar
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: 35
|
|
color: root.accentColor // Header same as background
|
|
topLeftRadius: (typeof Style !== "undefined") ? Style.radiusM : 16
|
|
topRightRadius: (typeof Style !== "undefined") ? Style.radiusM : 16
|
|
bottomLeftRadius: 0
|
|
bottomRightRadius: 0
|
|
|
|
RowLayout {
|
|
id: headerContent
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
anchors.margins: 8
|
|
spacing: 8
|
|
NIcon {
|
|
icon: root.typeIcon
|
|
pointSize: 13
|
|
color: root.accentFgColor
|
|
}
|
|
NText {
|
|
text: root.typeLabel
|
|
color: root.accentFgColor
|
|
font.bold: true
|
|
}
|
|
Item {
|
|
Layout.fillWidth: true
|
|
}
|
|
NIconButton {
|
|
id: todoButton
|
|
visible: root.enableTodoIntegration && !root.isImage
|
|
icon: "checkbox"
|
|
tooltipText: pluginApi?.tr("card.add-todo")
|
|
colorFg: root.accentFgColor
|
|
colorBg: "transparent"
|
|
colorBgHover: Qt.rgba(0, 0, 0, 0.1)
|
|
colorBorder: "transparent"
|
|
colorBorderHover: "transparent"
|
|
onClicked: {
|
|
// Close any previously open menu
|
|
if (root.panelRoot && root.panelRoot.activeContextMenu) {
|
|
root.panelRoot.activeContextMenu.visible = false;
|
|
}
|
|
|
|
// Position anchor at button location
|
|
const pos = todoButton.mapToItem(root, 0, todoButton.height);
|
|
menuAnchor.x = pos.x;
|
|
menuAnchor.y = pos.y;
|
|
|
|
// Show menu and register it as active
|
|
todoContextMenu.model = root.buildTodoMenuModel();
|
|
todoContextMenu.visible = true;
|
|
if (root.panelRoot) {
|
|
root.panelRoot.activeContextMenu = todoContextMenu;
|
|
}
|
|
}
|
|
}
|
|
NIconButton {
|
|
visible: !root.isPinned && (pluginApi?.pluginSettings?.pincardsEnabled ?? true) // Hide pin button if pinned or if feature disabled
|
|
icon: "pin"
|
|
tooltipText: pluginApi?.tr("card.pin")
|
|
colorFg: root.accentFgColor
|
|
colorBg: "transparent"
|
|
colorBgHover: Qt.rgba(0, 0, 0, 0.1)
|
|
colorBorder: "transparent"
|
|
colorBorderHover: "transparent"
|
|
onClicked: root.pinClicked()
|
|
}
|
|
NIconButton {
|
|
icon: "trash"
|
|
tooltipText: pluginApi?.tr("card.delete")
|
|
colorFg: root.accentFgColor
|
|
colorBg: "transparent"
|
|
colorBgHover: Qt.rgba(0, 0, 0, 0.1)
|
|
colorBorder: "transparent"
|
|
colorBorderHover: "transparent"
|
|
onClicked: root.deleteClicked()
|
|
}
|
|
}
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
z: -1
|
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
onClicked: mouse => {
|
|
if (mouse.button === Qt.RightButton) {
|
|
root.rightClicked()
|
|
} else {
|
|
root.clicked()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
width: parent.width - 10
|
|
Layout.alignment: Qt.AlignHCenter
|
|
height: 1
|
|
gradient: Gradient {
|
|
orientation: Gradient.Horizontal
|
|
GradientStop {
|
|
position: 0.0
|
|
color: "transparent"
|
|
}
|
|
GradientStop {
|
|
position: 0.5
|
|
color: root.separatorColor
|
|
}
|
|
GradientStop {
|
|
position: 1.0
|
|
color: "transparent"
|
|
}
|
|
}
|
|
}
|
|
|
|
Item {
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
Layout.margins: 8
|
|
clip: true
|
|
MouseArea {
|
|
id: mouseArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
onClicked: mouse => {
|
|
if (mouse.button === Qt.RightButton) {
|
|
root.rightClicked()
|
|
} else {
|
|
root.clicked()
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
visible: root.isColor
|
|
anchors.fill: parent
|
|
radius: 8
|
|
color: root.colorValue || "transparent"
|
|
border.width: 1
|
|
border.color: root.accentFgColor // Use FG color for border contrast
|
|
}
|
|
|
|
NText {
|
|
visible: !root.isColor && !root.isImage
|
|
anchors.left: parent.left
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
text: root.preview || ""
|
|
wrapMode: Text.Wrap
|
|
elide: Text.ElideRight
|
|
color: root.accentFgColor
|
|
font.pointSize: 11
|
|
verticalAlignment: Text.AlignTop
|
|
}
|
|
|
|
NImageRounded {
|
|
visible: root.isImage
|
|
anchors.fill: parent
|
|
radius: 8
|
|
imageFillMode: Image.PreserveAspectFit
|
|
imagePath: {
|
|
// For pinned images, use data URL directly
|
|
if (root.pinnedImageDataUrl) {
|
|
return root.pinnedImageDataUrl;
|
|
}
|
|
// For cliphist images, use cache (reactive binding via imageCacheRevision)
|
|
if (root.isImage && root.pluginApi?.mainInstance) {
|
|
// Force re-evaluation when cache changes by referencing revision
|
|
const revision = root.pluginApi.mainInstance.imageCacheRevision;
|
|
const cache = root.pluginApi.mainInstance.imageCache;
|
|
return cache[root.clipboardId] || "";
|
|
}
|
|
return "";
|
|
}
|
|
Component.onCompleted: {
|
|
// Only decode if not pinned (pinned images have data URL already)
|
|
if (!root.pinnedImageDataUrl && root.isImage && root.clipboardId && root.pluginApi?.mainInstance) {
|
|
root.pluginApi.mainInstance.decodeToDataUrl(root.clipboardId, root.mime, null);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Component.onDestruction: {
|
|
if (todoContextMenu && todoContextMenu.visible) {
|
|
todoContextMenu.visible = false;
|
|
}
|
|
}
|
|
}
|