Custom Formatter Plugin
A plugin that provides custom text formatting commands to transform selected text. Includes case conversion, text wrapping, and sorting.
This example demonstrates editor manipulation, text selection handling, and custom commands.
Features Demonstrated
- ✅ Getting and setting editor selection
- ✅ Reading and replacing selected text
- ✅ Text transformation functions
- ✅ Multiple command registration
- ✅ Configuration with user preferences
- ✅ Command palette integration
Project Structure
- package.json
- manifest.json
- index.js
- formatters.js
- config.json
- README.md
Complete Source Code
package.json
{
"name": "lokus-custom-formatter",
"version": "1.0.0",
"description": "Custom text formatting commands for Lokus",
"main": "index.js",
"keywords": ["lokus", "plugin", "formatter", "text-transformation"],
"author": "Your Name",
"license": "MIT",
"engines": {
"lokus": "^1.0.0"
},
"dependencies": {
"@lokus/plugin-sdk": "^1.0.0"
}
}manifest.json
{
"id": "lokus-custom-formatter",
"name": "Custom Formatter",
"version": "1.0.0",
"description": "Transform text with custom formatting commands",
"author": "Your Name",
"main": "index.js",
"activationEvents": [
"onCommand:custom-formatter.upperCase",
"onCommand:custom-formatter.lowerCase",
"onCommand:custom-formatter.titleCase",
"onCommand:custom-formatter.sortLines",
"onCommand:custom-formatter.reverseLines",
"onCommand:custom-formatter.wrapQuotes",
"onCommand:custom-formatter.removeBlankLines"
],
"contributes": {
"commands": [
{
"id": "custom-formatter.upperCase",
"title": "Transform to UPPERCASE",
"category": "Format"
},
{
"id": "custom-formatter.lowerCase",
"title": "Transform to lowercase",
"category": "Format"
},
{
"id": "custom-formatter.titleCase",
"title": "Transform to Title Case",
"category": "Format"
},
{
"id": "custom-formatter.sortLines",
"title": "Sort Lines Alphabetically",
"category": "Format"
},
{
"id": "custom-formatter.reverseLines",
"title": "Reverse Lines",
"category": "Format"
},
{
"id": "custom-formatter.wrapQuotes",
"title": "Wrap in Quotes",
"category": "Format"
},
{
"id": "custom-formatter.removeBlankLines",
"title": "Remove Blank Lines",
"category": "Format"
}
],
"configuration": {
"title": "Custom Formatter",
"properties": {
"customFormatter.quoteStyle": {
"type": "string",
"enum": ["double", "single", "backtick"],
"default": "double",
"description": "Quote style for wrapping text"
},
"customFormatter.sortCaseSensitive": {
"type": "boolean",
"default": false,
"description": "Case-sensitive line sorting"
}
}
}
},
"permissions": []
}formatters.js
/**
* Text transformation functions
*/
/**
* Convert text to UPPERCASE
*/
export function toUpperCase(text) {
return text.toUpperCase();
}
/**
* Convert text to lowercase
*/
export function toLowerCase(text) {
return text.toLowerCase();
}
/**
* Convert text to Title Case
*/
export function toTitleCase(text) {
return text
.toLowerCase()
.split(' ')
.map(word => {
// Don't capitalize articles, conjunctions, prepositions (unless first word)
const smallWords = ['a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'in', 'of', 'on', 'or', 'the', 'to'];
if (smallWords.includes(word)) {
return word;
}
return word.charAt(0).toUpperCase() + word.slice(1);
})
.map((word, index) => {
// Always capitalize first word
if (index === 0) {
return word.charAt(0).toUpperCase() + word.slice(1);
}
return word;
})
.join(' ');
}
/**
* Sort lines alphabetically
*/
export function sortLines(text, caseSensitive = false) {
const lines = text.split('\n');
if (caseSensitive) {
lines.sort();
} else {
lines.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
}
return lines.join('\n');
}
/**
* Reverse line order
*/
export function reverseLines(text) {
return text.split('\n').reverse().join('\n');
}
/**
* Wrap text in quotes
*/
export function wrapInQuotes(text, quoteStyle = 'double') {
const quotes = {
double: '"',
single: "'",
backtick: '`'
};
const quote = quotes[quoteStyle] || quotes.double;
// Split by lines and wrap each line
return text
.split('\n')
.map(line => line.trim() ? `${quote}${line}$\\{quote\\}` : line)
.join('\n');
}
/**
* Remove blank lines
*/
export function removeBlankLines(text) {
return text
.split('\n')
.filter(line => line.trim().length > 0)
.join('\n');
}
/**
* Add line numbers
*/
export function addLineNumbers(text) {
return text
.split('\n')
.map((line, index) => `${index + 1}. $\\{line\\}`)
.join('\n');
}
/**
* Duplicate lines
*/
export function duplicateLines(text) {
return text
.split('\n')
.map(line => `${line}\n$\\{line\\}`)
.join('\n');
}index.js
/**
* Custom Formatter Plugin for Lokus
*
* Provides text transformation commands for the editor
*/
import * as formatters from './formatters.js';
/**
* Apply a formatter to the selected text
* @param {Object} api - Lokus Plugin API
* @param {Function} formatter - Formatter function
* @param {string} commandName - Name of the command for error messages
*/
async function applyFormatter(api, formatter, commandName) {
try {
// Get current selection
const selection = await api.editor.getSelection();
if (!selection) {
api.ui.showWarningMessage('No active editor');
return;
}
// Check if there's selected text
if (selection.isEmpty) {
api.ui.showInformationMessage('Please select some text first');
return;
}
// Get the selected text
const selectedText = await api.editor.getTextInRange({
start: selection.start,
end: selection.end
});
if (!selectedText) {
api.ui.showWarningMessage('Could not get selected text');
return;
}
// Apply the formatter
const formattedText = formatter(selectedText);
// Replace the selection with formatted text
await api.editor.replaceText(
{ start: selection.start, end: selection.end },
formattedText
);
// Show success message
api.ui.showInformationMessage(`Applied $\\{commandName\\}`);
} catch (error) {
console.error(`Error in ${commandName}:`, error);
api.ui.showErrorMessage(`Failed to apply ${commandName}: $\\{error.message\\}`);
}
}
/**
* Called when the plugin is activated
*/
export function activate(api) {
console.log('Custom Formatter plugin activated');
// Get configuration
const config = api.config.getConfiguration('customFormatter');
const quoteStyle = config.get('quoteStyle', 'double');
const sortCaseSensitive = config.get('sortCaseSensitive', false);
// Array to store all disposables
const disposables = [];
// Register uppercase command
disposables.push(
api.commands.register({
id: 'custom-formatter.upperCase',
title: 'Transform to UPPERCASE',
execute: () => applyFormatter(api, formatters.toUpperCase, 'UPPERCASE')
})
);
// Register lowercase command
disposables.push(
api.commands.register({
id: 'custom-formatter.lowerCase',
title: 'Transform to lowercase',
execute: () => applyFormatter(api, formatters.toLowerCase, 'lowercase')
})
);
// Register title case command
disposables.push(
api.commands.register({
id: 'custom-formatter.titleCase',
title: 'Transform to Title Case',
execute: () => applyFormatter(api, formatters.toTitleCase, 'Title Case')
})
);
// Register sort lines command
disposables.push(
api.commands.register({
id: 'custom-formatter.sortLines',
title: 'Sort Lines Alphabetically',
execute: () => applyFormatter(
api,
(text) => formatters.sortLines(text, sortCaseSensitive),
'Sort Lines'
)
})
);
// Register reverse lines command
disposables.push(
api.commands.register({
id: 'custom-formatter.reverseLines',
title: 'Reverse Lines',
execute: () => applyFormatter(api, formatters.reverseLines, 'Reverse Lines')
})
);
// Register wrap quotes command
disposables.push(
api.commands.register({
id: 'custom-formatter.wrapQuotes',
title: 'Wrap in Quotes',
execute: () => applyFormatter(
api,
(text) => formatters.wrapInQuotes(text, quoteStyle),
'Wrap in Quotes'
)
})
);
// Register remove blank lines command
disposables.push(
api.commands.register({
id: 'custom-formatter.removeBlankLines',
title: 'Remove Blank Lines',
execute: () => applyFormatter(api, formatters.removeBlankLines, 'Remove Blank Lines')
})
);
// Listen for configuration changes
const configDisposable = api.config.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration('customFormatter')) {
api.ui.showInformationMessage('Custom Formatter configuration updated');
}
});
disposables.push(configDisposable);
// Return cleanup function
return {
dispose: () => {
disposables.forEach(d => d.dispose());
console.log('Custom Formatter plugin deactivated');
}
};
}
/**
* Called when the plugin is deactivated
*/
export function deactivate() {
// Additional cleanup if needed
}Installation & Testing
Step 1: Create the Plugin
mkdir lokus-custom-formatter
cd lokus-custom-formatter
# Copy all files above
npm installStep 2: Link and Enable
npm link
# In Lokus plugins directory
npm link lokus-custom-formatterStep 3: Test the Commands
- Open Lokus and enable the plugin
- Open a document and select some text
- Open Command Palette (
Cmd+Shift+P) - Try these commands:
- “Transform to UPPERCASE” - converts to ALL CAPS
- “Transform to lowercase” - converts to all lowercase
- “Transform to Title Case” - Converts To Title Case
- “Sort Lines Alphabetically” - sorts selected lines
- “Reverse Lines” - reverses line order
- “Wrap in Quotes” - wraps each line in quotes
- “Remove Blank Lines” - removes empty lines
Code Walkthrough
Getting Selected Text
// 1. Get the selection
const selection = await api.editor.getSelection();
// 2. Check if text is selected
if (selection.isEmpty) {
api.ui.showInformationMessage('Please select some text first');
return;
}
// 3. Get the text in the selection
const selectedText = await api.editor.getTextInRange({
start: selection.start,
end: selection.end
});Selection objects have:
start- Starting position\\\{line, character\\\}end- Ending position\\\{line, character\\\}isEmpty- Boolean indicating if nothing is selected
Replacing Text
await api.editor.replaceText(
{ start: selection.start, end: selection.end },
formattedText
);This replaces the text in the given range with new text. The editor cursor will be positioned at the end of the replacement.
Applying Formatters
The applyFormatter helper function:
- Gets the current selection
- Extracts the selected text
- Applies the formatter function
- Replaces the selection with formatted text
- Handles errors gracefully
async function applyFormatter(api, formatter, commandName) {
// ... get selection and text ...
const formattedText = formatter(selectedText);
await api.editor.replaceText(range, formattedText);
}Using Configuration
const config = api.config.getConfiguration('customFormatter');
const quoteStyle = config.get('quoteStyle', 'double');Reads user preferences defined in manifest.json. The second parameter is the default value.
Listening to Config Changes
api.config.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration('customFormatter')) {
// Reload configuration
}
});This allows your plugin to react when users change settings.
Extension Ideas
Add more formatters:
-
Markdown Formatting
- Bold selected text (
**text**) - Italicize (
*text*) - Create links
- Convert to code blocks
- Bold selected text (
-
Advanced Transformations
- Snake_case, camelCase, kebab-case conversion
- Remove duplicate lines
- Trim whitespace
- ROT13 encoding
-
Programming Helpers
- Comment/uncomment lines
- Generate TODO items
- Add JSDoc templates
- Format JSON
Example: Bold Formatter
// In formatters.js
export function toBold(text) {
return `**${text}**`;
}
// In index.js
disposables.push(
api.commands.register({
id: 'custom-formatter.bold',
title: 'Make Bold',
execute: () => applyFormatter(api, formatters.toBold, 'Bold')
})
);Example: Case Converter
export function toCamelCase(text) {
return text
.replace(/[-_\s]+(.)?/g, (_, char) => char ? char.toUpperCase() : '')
.replace(/^(.)/, (char) => char.toLowerCase());
}
export function toSnakeCase(text) {
return text
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
}
export function toKebabCase(text) {
return text
.replace(/([A-Z])/g, '-$1')
.toLowerCase()
.replace(/^-/, '');
}Testing Tips
Test with Different Selections
Test single line
Test
multiple
lines
Test with spacesTest Edge Cases
- Empty selection
- Entire document
- Selection with special characters
- Unicode text
- Very long selections
Add Unit Tests
// formatters.test.js
import { toUpperCase, toTitleCase } from './formatters.js';
console.assert(toUpperCase('hello') === 'HELLO');
console.assert(toTitleCase('hello world') === 'Hello World');Common Issues
Issue: Command doesn’t appear in palette
Solution: Check that command ID in manifest.json matches the one in commands.register()
Issue: Text not replaced correctly
Make sure to use the correct range format:
// ✅ Correct
{ start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }
// ❌ Wrong
{ from: 0, to: 5 } // This is TipTap format, not API formatPerformance Considerations
For very large selections:
if (selectedText.length > 1000000) { // 1MB
const proceed = await api.ui.showConfirm({
title: 'Large Selection',
message: 'This selection is very large. Continue?'
});
if (!proceed) return;
}Next Steps
Learn more advanced topics:
- File Explorer - Build tree view UIs
- AI Assistant - Network requests and streaming
- Editor API Reference - Complete editor API
Ready for more? Continue to File Explorer Example to build tree view interfaces.