Skip to content

Commit

Permalink
Add commandPalette
Browse files Browse the repository at this point in the history
  • Loading branch information
baku89 committed Sep 29, 2023
1 parent 9c43813 commit d581f0e
Show file tree
Hide file tree
Showing 9 changed files with 342 additions and 51 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"@typescript-eslint/parser": "^6.7.0",
"@vitejs/plugin-vue": "^4.3.4",
"@vueuse/core": "^10.4.1",
"bndr-js": "^0.9.4",
"bndr-js": "^0.10.0",
"case": "^1.6.3",
"eslint": "^8.49.0",
"eslint-config-prettier": "^9.0.0",
Expand All @@ -28,6 +28,7 @@
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-unused-imports": "^3.0.0",
"eslint-plugin-vue": "^9.17.0",
"fast-fuzzy": "^1.12.0",
"linearly": "^0.12.0",
"monaco-editor": "^0.43.0",
"monaco-themes": "^0.4.4",
Expand Down
111 changes: 68 additions & 43 deletions src/components/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import PaperOffset from 'paperjs-offset'
PaperOffset(paper)
import {useTweeq} from '@/tweeq'
import CommandPalette from '@/tweeq/CommandPalette'
import FloatingPane from '@/tweeq/FloatingPane'
import MonacoEditor, {ErrorInfo} from '@/tweeq/MonacoEditor'
import RoundButton from '@/tweeq/RoundButton'
Expand All @@ -34,7 +35,9 @@ import OverlayColorPicker from './OverlayColorPicker.vue'
import OverlayNumberSlider from './OverlayNumberSlider.vue'
import OverlayPointHandle from './OverlayPointHandle.vue'
const {appStorage} = useTweeq('com.baku89.paperjs-editor')
const {appStorage, registerActions, performAction} = useTweeq(
'com.baku89.paperjs-editor'
)
interface PaperDesc {
id?: string
Expand Down Expand Up @@ -180,10 +183,6 @@ const zoom = computed(() => {
return Math.sqrt(mat2d.determinant(viewTransform.value))
})
function resetZoom() {
viewTransform.value = initialViewTransform.value
}
const canvasGridStyle = computed(() => {
const [x, y] = vec2.transformMat2d(vec2.zero, viewTransform.value)
Expand Down Expand Up @@ -219,23 +218,6 @@ onMounted(() => {
const colorPickerVisible = ref(false)
function copyCanvasAsSVG() {
const svg = paper.project.exportSVG({asString: true})
navigator.clipboard.writeText(svg.toString())
}
async function pasteSVGToCanvas() {
const svg = await navigator.clipboard.readText()
const svgCode = `project.importSVG(\`${svg}\`)\n`
code.value = replaceTextBetween(
code.value,
cursorIndex.value,
cursorIndex.value,
svgCode
)
}
const fileHandle = ref<FileSystemFileHandle | null>(null)
const lastSavedSource = ref('')
Expand Down Expand Up @@ -264,36 +246,78 @@ const filePickerOptions: FilePickerOptions = {
],
}
async function saveProject() {
if (!fileHandle.value) {
fileHandle.value = await window.showSaveFilePicker(filePickerOptions)
}
const writable = await fileHandle.value.createWritable()
await writable.write(source.value)
await writable.close()
lastSavedSource.value = source.value
}
async function openProject() {
const handles = await window.showOpenFilePicker(filePickerOptions)
registerActions([
{
id: 'reset-zoom',
label: 'Reset Zoom',
icon: 'zoom_out_map',
perform() {
viewTransform.value = initialViewTransform.value
},
},
{
id: 'copy-canvas-as-svg',
label: 'Copy Canvas as SVG',
icon: 'content_copy',
perform() {
const svg = paper.project.exportSVG({asString: true})
navigator.clipboard.writeText(svg.toString())
},
},
{
id: 'paste-svg-to-canvas',
label: 'Paste SVG to Canvas',
icon: 'content_paste',
async perform() {
const svg = await navigator.clipboard.readText()
const svgCode = `project.importSVG(\`${svg}\`)\n`
code.value = replaceTextBetween(
code.value,
cursorIndex.value,
cursorIndex.value,
svgCode
)
},
},
{
id: 'open-project',
label: 'Open Project',
async perform() {
const handles = await window.showOpenFilePicker(filePickerOptions)
fileHandle.value = handles[0]
const file = await fileHandle.value.getFile()
const text = await file.text()
source.value = lastSavedSource.value = text
},
},
{
id: 'save-project',
label: 'Save Project',
async perform() {
if (!fileHandle.value) {
fileHandle.value = await window.showSaveFilePicker(filePickerOptions)
}
fileHandle.value = handles[0]
const writable = await fileHandle.value.createWritable()
await writable.write(source.value)
await writable.close()
const file = await fileHandle.value.getFile()
const text = await file.text()
source.value = lastSavedSource.value = text
}
lastSavedSource.value = source.value
},
},
])
// Register shotcuts
window.addEventListener('keydown', e => {
if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
saveProject()
performAction('save-project')
} else if (e.key === 'o' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
openProject()
performAction('open-project')
}
})
Expand All @@ -315,6 +339,7 @@ window.addEventListener('drop', async e => {

<template>
<div class="App">
<CommandPalette />
<TitleBar name="Paper.js Editor" class="title" icon="favicon.svg">
<template #left>
{{ title }}
Expand Down
159 changes: 159 additions & 0 deletions src/tweeq/CommandPalette/CommandPalette.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<script setup lang="ts">
import * as Bndr from 'bndr-js'
import {search} from 'fast-fuzzy'
import {computed, ref, watch} from 'vue'
import {type Action, useActions} from '../action'
const {actions} = useActions()
// console.log(toRaw(actions))
const $popover = ref<HTMLElement | null>(null)
const searchWord = ref('')
const filteredActions = computed(() => {
return search(searchWord.value, Object.values(actions), {
keySelector: action => action.label,
})
})
const selectedAction = ref<null | Action>(null)
watch(filteredActions, () => {
if (filteredActions.value.length > 0) {
const notFoundInFiltered = !filteredActions.value.includes(
selectedAction.value!
)
if (notFoundInFiltered) {
selectedAction.value = filteredActions.value[0]
}
} else {
selectedAction.value = null
}
})
Bndr.keyboard()
.key('command+p', {preventDefault: true, capture: true, passive: true})
.on(() => {
const open = $popover.value?.togglePopover()
if (open) {
searchWord.value = ''
$popover.value?.querySelector('input')?.focus()
}
})
function onKeydown(e: KeyboardEvent) {
if (e.key === 'p' && e.metaKey) {
e.preventDefault()
$popover.value?.hidePopover()
}
if (selectedAction.value && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
const index = filteredActions.value.indexOf(selectedAction.value)
const length = filteredActions.value.length
const move = e.key === 'ArrowDown' ? 1 : -1
const newIndex = (index + move + length) % length
selectedAction.value = filteredActions.value[newIndex]
}
if (e.key === 'Enter' && selectedAction.value) {
perform(selectedAction.value)
}
}
function perform(action: Action) {
searchWord.value = ''
$popover.value?.hidePopover()
action.perform()
}
</script>

<template>
<div ref="$popover" class="CommandPalette" popover>
<div class="search-container">
<span class="material-symbols-outlined">search</span>
<input
v-model="searchWord"
class="search"
type="text"
placeholder="Search menus and commands"
@keydown="onKeydown"
/>
</div>
<ul class="actions">
<li
v-for="action in filteredActions"
:key="action.id"
class="action"
:class="{selected: action === selectedAction}"
@pointerenter="selectedAction = action"
@click="perform(action)"
>
<span class="action-icon material-symbols-outlined">{{
action.icon
}}</span>
{{ action.label }}
</li>
</ul>
</div>
</template>

<style lang="stylus" scoped>
@import '../common.styl'
.CommandPalette
width 400px
background red
top 20vh
margin 0 auto
border-radius 8px
color var(--tq-color-inverse-on-surface)
background var(--tq-color-inverse-surface)
padding 0 9px
box-shadow 0 0 30px -15px var(--tq-color-shadow)
.search-container
display flex
align-items center
gap 6px
padding-left 3px
.material-symbols-outlined
display block
.search
display block
flex-grow 1
font-size 1.2rem
height 48px
line-height 36px
&::placeholder
font-size 1.2rem
color var(--tq-color-inverse-on-surface)
opacity .3
.action
display flex
align-items center
gap 18px
line-height 32px
border-radius 3px
padding 0 6px
&:last-child
margin-bottom 9px
&.selected
background var(--tq-color-inverse-primary)
color var(--tq-color-inverse-surface)
.action-icon
width 20px
font-size 20px
</style>
2 changes: 2 additions & 0 deletions src/tweeq/CommandPalette/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import CommandPalette from './CommandPalette.vue'
export default CommandPalette
54 changes: 54 additions & 0 deletions src/tweeq/action.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {inject, InjectionKey, onBeforeUnmount, provide, reactive} from 'vue'

export interface Action {
id: string
label: string
shortLabel?: string
icon?: string
perform(...args: any): any
}

const ActionsKey: InjectionKey<Record<string, Action>> = Symbol('tqActions')

export function provideActions() {
const allActions = reactive<Record<string, Action>>({})

provide(ActionsKey, allActions)

function registerActions(actions: Action[]) {
for (const action of actions) {
if (action.id in actions) {
throw new Error(`Action ${action.id} is already registered`)
}

allActions[action.id] = action
}

onBeforeUnmount(() => {
for (const action of actions) {
delete allActions[action.id]
}
})
}

function performAction(id: string, ...args: any) {
const action = allActions[id]
if (!action) {
throw new Error(`Action ${id} is not registered`)
}

action.perform(...args)
}

return {registerActions, performAction}
}

export function useActions() {
const actions = inject(ActionsKey)

if (!actions) {
throw new Error('actions is not provided')
}

return {actions}
}
Loading

0 comments on commit d581f0e

Please sign in to comment.