Compare commits

...

2 commits

Author SHA1 Message Date
isUnknown
93df05c49f feat: implement inheritance lock/unlock with CSS commenting system
All checks were successful
Deploy / Build and Deploy to Production (push) Successful in 16s
Add ability to lock/unlock inheritance for element styles while preserving
custom values. Locked styles are commented in the CSS and restored when unlocked.

New utilities:
- Create css-comments.js with comment/uncomment functions
- Add parseValueWithUnit to css-parsing.js for value parsing
- Add getBlockState, commentCssBlock, uncommentCssBlock to stylesheet store

ElementPopup improvements:
- Detect inheritance state from CSS block state (active/commented/none)
- Capture computed styles from iframe when unlocking with no custom CSS
- Comment/uncomment CSS blocks instead of deleting them on lock toggle
- Use nextTick to prevent race condition with watchers during popup init
- Extract values from both active and commented CSS blocks

Workflow:
1. First unlock: Capture computed styles → create CSS block
2. Lock: Comment the CSS block (styles preserved in comments)
3. Unlock again: Uncomment the block (styles restored)

Fixes issue where CSS rules were created on popup open due to
watcher race conditions during initialization.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 14:31:42 +01:00
isUnknown
b123e92da8 ci: add --delete flag to FTP mirror commands
Enable automatic deletion of remote files that no longer exist locally.
This ensures the production server stays in sync with the repository,
removing obsolete files like the renamed stylesheet.css.

Protected directories (accounts, cache, sessions) remain excluded.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 13:59:54 +01:00
5 changed files with 304 additions and 29 deletions

View file

@ -42,21 +42,21 @@ jobs:
lftp -c " lftp -c "
set ftp:ssl-allow no; set ftp:ssl-allow no;
open -u $USERNAME,$PASSWORD $PRODUCTION_HOST; open -u $USERNAME,$PASSWORD $PRODUCTION_HOST;
mirror --reverse --verbose --ignore-time --parallel=10 \ mirror --reverse --delete --verbose --ignore-time --parallel=10 \
-x 'local/' \ -x 'local/' \
-x 'css/src/' \ -x 'css/src/' \
-x 'css/style.css' \ -x 'css/style.css' \
-x 'css/style.css.map' \ -x 'css/style.css.map' \
-x 'css/style.scss' \ -x 'css/style.scss' \
assets assets; assets assets;
mirror --reverse --verbose --ignore-time --parallel=10 \ mirror --reverse --delete --verbose --ignore-time --parallel=10 \
-x 'accounts/' \ -x 'accounts/' \
-x 'cache/' \ -x 'cache/' \
-x 'sessions/' \ -x 'sessions/' \
site site; site site;
mirror --reverse --verbose --ignore-time --parallel=10 \ mirror --reverse --delete --verbose --ignore-time --parallel=10 \
kirby kirby; kirby kirby;
mirror --reverse --verbose --ignore-time --parallel=10 \ mirror --reverse --delete --verbose --ignore-time --parallel=10 \
vendor vendor; vendor vendor;
put index.php -o index.php; put index.php -o index.php;
quit" quit"

View file

@ -258,7 +258,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue'; import { ref, computed, watch, nextTick } from 'vue';
import { useStylesheetStore } from '../stores/stylesheet'; import { useStylesheetStore } from '../stores/stylesheet';
import { usePopupPosition } from '../composables/usePopupPosition'; import { usePopupPosition } from '../composables/usePopupPosition';
import { useDebounce } from '../composables/useDebounce'; import { useDebounce } from '../composables/useDebounce';
@ -610,8 +610,6 @@ const loadValuesFromStylesheet = () => {
if (!selector.value) return; if (!selector.value) return;
try { try {
isUpdatingFromStore = true;
// Extract font-family // Extract font-family
const fontFamilyData = stylesheetStore.extractValue(selector.value, 'font-family'); const fontFamilyData = stylesheetStore.extractValue(selector.value, 'font-family');
if (fontFamilyData) { if (fontFamilyData) {
@ -676,12 +674,13 @@ const loadValuesFromStylesheet = () => {
} }
} catch (error) { } catch (error) {
console.error('Error loading values from stylesheet:', error); console.error('Error loading values from stylesheet:', error);
} finally {
isUpdatingFromStore = false;
} }
}; };
const open = (element, event, count = null) => { const open = (element, event, count = null) => {
// Block all watchers during initialization
isUpdatingFromStore = true;
selectedElement.value = element; selectedElement.value = element;
selector.value = getSelectorFromElement(element); selector.value = getSelectorFromElement(element);
position.value = calculatePosition(event); position.value = calculatePosition(event);
@ -689,14 +688,30 @@ const open = (element, event, count = null) => {
// Store instance count if provided, otherwise calculate it // Store instance count if provided, otherwise calculate it
elementInstanceCount.value = count !== null ? count : getInstanceCount(selector.value); elementInstanceCount.value = count !== null ? count : getInstanceCount(selector.value);
// Read inheritance state from element's data attribute // Detect inheritance state from CSS block state
inheritanceLocked.value = element.dataset.inheritanceUnlocked !== 'true'; const blockState = stylesheetStore.getBlockState(selector.value);
// Load values from stylesheet if (blockState === 'active') {
// Block exists and is active (not commented) unlocked
inheritanceLocked.value = false;
} else if (blockState === 'commented') {
// Block exists but is commented locked with custom values
inheritanceLocked.value = true;
} else {
// No block locked with inherited values
inheritanceLocked.value = true;
}
// Load values from stylesheet (includes commented blocks)
loadValuesFromStylesheet(); loadValuesFromStylesheet();
visible.value = true; visible.value = true;
// Re-enable watchers after initialization (use nextTick to ensure watchers see the flag)
nextTick(() => {
isUpdatingFromStore = false;
});
// Initialize Coloris after opening // Initialize Coloris after opening
setTimeout(() => { setTimeout(() => {
Coloris.init(); Coloris.init();
@ -755,24 +770,69 @@ const handleIframeClick = (event, targetElement = null, elementCount = null) =>
}; };
const toggleInheritance = () => { const toggleInheritance = () => {
const wasLocked = inheritanceLocked.value; const blockState = stylesheetStore.getBlockState(selector.value);
inheritanceLocked.value = !inheritanceLocked.value;
// Store the inheritance state in the element's data attribute if (inheritanceLocked.value && blockState === 'commented') {
if (selectedElement.value) { // Case 1: Locked with commented block Uncomment to unlock
if (inheritanceLocked.value) { stylesheetStore.uncommentCssBlock(selector.value);
delete selectedElement.value.dataset.inheritanceUnlocked; inheritanceLocked.value = false;
} else { } else if (inheritanceLocked.value && blockState === 'none') {
selectedElement.value.dataset.inheritanceUnlocked = 'true'; // Case 2: Locked with no custom CSS Capture computed values and create block
if (selectedElement.value && props.iframeRef && props.iframeRef.contentWindow) {
const computed = props.iframeRef.contentWindow.getComputedStyle(selectedElement.value);
// Update fields with computed values before creating the block
isUpdatingFromStore = true;
// Font family
fontFamily.value.value = computed.fontFamily.replace(/['"]/g, '').split(',')[0].trim();
// Font style
fontStyle.value.italic = computed.fontStyle === 'italic';
// Font weight
fontWeight.value.value = parseInt(computed.fontWeight);
// Font size
const fontSizeMatch = computed.fontSize.match(/([\d.]+)(px|rem|em|pt)/);
if (fontSizeMatch) {
fontSize.value.value = parseFloat(fontSizeMatch[1]);
fontSize.value.unit = fontSizeMatch[2];
}
// Text align
textAlign.value.value = computed.textAlign;
// Color
color.value.value = computed.color;
// Background
background.value.value = computed.backgroundColor;
// Margin (take the top margin)
const marginMatch = computed.marginTop.match(/([\d.]+)(px|mm|pt)/);
if (marginMatch) {
marginOuter.value.value = parseFloat(marginMatch[1]);
marginOuter.value.unit = marginMatch[2];
}
// Padding (take the top padding)
const paddingMatch = computed.paddingTop.match(/([\d.]+)(px|mm|pt)/);
if (paddingMatch) {
paddingInner.value.value = parseFloat(paddingMatch[1]);
paddingInner.value.unit = paddingMatch[2];
}
isUpdatingFromStore = false;
} }
}
if (inheritanceLocked.value && !wasLocked) { // Now create the block with captured values
// Re-locking: remove the element-specific CSS block to restore inheritance
removeElementBlock();
} else if (!inheritanceLocked.value && wasLocked) {
// Unlocking: apply all current field values to create the CSS block
applyAllStyles(); applyAllStyles();
inheritanceLocked.value = false;
} else if (!inheritanceLocked.value && blockState === 'active') {
// Case 3: Unlocked with active block Comment to lock
stylesheetStore.commentCssBlock(selector.value);
inheritanceLocked.value = true;
} }
}; };

View file

@ -1,6 +1,7 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import cssParsingUtils from '../utils/css-parsing'; import cssParsingUtils from '../utils/css-parsing';
import * as cssComments from '../utils/css-comments';
import prettier from 'prettier/standalone'; import prettier from 'prettier/standalone';
import parserPostcss from 'prettier/plugins/postcss'; import parserPostcss from 'prettier/plugins/postcss';
import { getCsrfToken } from '../utils/kirby-auth'; import { getCsrfToken } from '../utils/kirby-auth';
@ -81,10 +82,24 @@ export const useStylesheetStore = defineStore('stylesheet', () => {
}); });
}; };
const extractValue = (selector, property) => { const extractValue = (selector, property, includeCommented = true) => {
// Try to extract from custom CSS first, then from base CSS // Try to extract from active custom CSS first
const customValue = cssParsingUtils.extractCssValue(customCss.value, selector, property); const customValue = cssParsingUtils.extractCssValue(customCss.value, selector, property);
if (customValue) return customValue; if (customValue) return customValue;
// If includeCommented, try to extract from commented block
if (includeCommented) {
const commentedBlock = cssComments.extractCommentedBlock(customCss.value, selector);
if (commentedBlock) {
const commentedValue = cssComments.extractValueFromCommentedBlock(commentedBlock, property);
if (commentedValue) {
// Parse value with unit if needed
return cssParsingUtils.parseValueWithUnit(commentedValue);
}
}
}
// Finally, try base CSS
return cssParsingUtils.extractCssValue(baseCss.value, selector, property); return cssParsingUtils.extractCssValue(baseCss.value, selector, property);
}; };
@ -117,6 +132,29 @@ export const useStylesheetStore = defineStore('stylesheet', () => {
customCss.value = newCss; customCss.value = newCss;
}; };
// Comment a CSS block in custom CSS
const commentCssBlock = (selector) => {
const block = cssParsingUtils.extractCssBlock(customCss.value, selector);
if (!block) return;
customCss.value = cssComments.commentBlock(customCss.value, block);
};
// Uncomment a CSS block in custom CSS
const uncommentCssBlock = (selector) => {
customCss.value = cssComments.uncommentBlock(customCss.value, selector);
};
// Check if a CSS block is commented
const isBlockCommented = (selector) => {
return cssComments.isBlockCommented(customCss.value, selector);
};
// Get the state of a CSS block
const getBlockState = (selector) => {
return cssComments.getBlockState(customCss.value, selector);
};
// Load base CSS from stylesheet.print.css // Load base CSS from stylesheet.print.css
const loadBaseCss = async () => { const loadBaseCss = async () => {
const response = await fetch('/assets/css/stylesheet.print.css'); const response = await fetch('/assets/css/stylesheet.print.css');
@ -228,6 +266,10 @@ export const useStylesheetStore = defineStore('stylesheet', () => {
replaceBlock, replaceBlock,
replaceInCustomCss, replaceInCustomCss,
setCustomCss, setCustomCss,
commentCssBlock,
uncommentCssBlock,
isBlockCommented,
getBlockState,
formatCustomCss, formatCustomCss,
loadBaseCss, loadBaseCss,
initializeFromNarrative, initializeFromNarrative,

155
src/utils/css-comments.js Normal file
View file

@ -0,0 +1,155 @@
/**
* CSS Comments Utility
* Handles commenting/uncommenting CSS blocks for inheritance control
*/
/**
* Check if a CSS block is commented
* @param {string} css - The CSS content
* @param {string} selector - The CSS selector to check
* @returns {boolean}
*/
export function isBlockCommented(css, selector) {
const commentedBlock = extractCommentedBlock(css, selector);
return commentedBlock !== null;
}
/**
* Extract a commented CSS block for a selector
* @param {string} css - The CSS content
* @param {string} selector - The CSS selector
* @returns {string|null} - The commented block or null if not found
*/
export function extractCommentedBlock(css, selector) {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(
`/\\*\\s*\\n?${escaped}\\s*\\{[^}]*\\}\\s*\\n?\\*/`,
'g'
);
const match = css.match(regex);
return match ? match[0] : null;
}
/**
* Comment a CSS block
* @param {string} css - The CSS content
* @param {string} blockContent - The CSS block to comment
* @returns {string} - CSS with the block commented
*/
export function commentBlock(css, blockContent) {
if (!blockContent || !css.includes(blockContent)) return css;
const commented = `/*\n${blockContent}\n*/`;
return css.replace(blockContent, commented);
}
/**
* Uncomment a CSS block for a selector
* @param {string} css - The CSS content
* @param {string} selector - The CSS selector
* @returns {string} - CSS with the block uncommented
*/
export function uncommentBlock(css, selector) {
const commentedBlock = extractCommentedBlock(css, selector);
if (!commentedBlock) return css;
// Remove comment delimiters /* and */
const uncommented = commentedBlock
.replace(/^\/\*\s*\n?/, '')
.replace(/\s*\n?\*\/$/, '');
return css.replace(commentedBlock, uncommented);
}
/**
* Get the state of a CSS block
* @param {string} css - The CSS content
* @param {string} selector - The CSS selector
* @returns {'active'|'commented'|'none'}
*/
export function getBlockState(css, selector) {
// Check for commented block
if (isBlockCommented(css, selector)) {
return 'commented';
}
// Check for active block
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`${escaped}\\s*\\{[^}]*\\}`, 'g');
const activeMatch = css.match(regex);
if (activeMatch && activeMatch.length > 0) {
return 'active';
}
return 'none';
}
/**
* Extract a value from a commented CSS block
* @param {string} commentedBlock - The commented CSS block
* @param {string} property - The CSS property to extract
* @returns {string|null} - The value or null if not found
*/
export function extractValueFromCommentedBlock(commentedBlock, property) {
if (!commentedBlock) return null;
// Remove comment delimiters
const cssContent = commentedBlock
.replace(/^\/\*\s*\n?/, '')
.replace(/\s*\n?\*\/$/, '');
// Extract property value
const regex = new RegExp(`${property}\\s*:\\s*([^;]+)`, 'i');
const match = cssContent.match(regex);
return match ? match[1].trim() : null;
}
/**
* Extract all commented blocks from CSS
* Used for preserving comments during formatting
* @param {string} css - The CSS content
* @returns {Array<{selector: string, block: string}>}
*/
export function extractAllCommentedBlocks(css) {
const blocks = [];
const regex = /\/\*\s*\n?([.\w\s>#:-]+)\s*\{[^}]*\}\s*\n?\*\//g;
let match;
while ((match = regex.exec(css)) !== null) {
blocks.push({
selector: match[1].trim(),
block: match[0]
});
}
return blocks;
}
/**
* Remove all commented blocks from CSS
* Used before formatting
* @param {string} css - The CSS content
* @returns {string} - CSS without commented blocks
*/
export function removeAllCommentedBlocks(css) {
return css.replace(/\/\*\s*\n?[.\w\s>#:-]+\s*\{[^}]*\}\s*\n?\*\//g, '');
}
/**
* Reinsert commented blocks into formatted CSS
* @param {string} css - The formatted CSS
* @param {Array<{selector: string, block: string}>} blocks - Commented blocks to reinsert
* @returns {string} - CSS with commented blocks reinserted
*/
export function reinsertCommentedBlocks(css, blocks) {
let result = css;
// Append commented blocks at the end
blocks.forEach(({ block }) => {
result += '\n' + block;
});
return result;
}

View file

@ -44,6 +44,24 @@ const updateCssValue = ({ css, selector, property, value, unit }) => {
return css.replace(selectorRegex, `${selector} {${newBlockContent}}`); return css.replace(selectorRegex, `${selector} {${newBlockContent}}`);
}; };
const cssParsingUtils = { extractCssBlock, extractCssValue, updateCssValue }; const parseValueWithUnit = (cssValue) => {
if (!cssValue) return null;
// Match number with optional unit
const match = cssValue.match(/([\d.]+)(px|rem|em|mm|cm|in)?/);
if (!match) return cssValue; // Return as-is if no match
return {
value: parseFloat(match[1]),
unit: match[2] || ''
};
};
const cssParsingUtils = {
extractCssBlock,
extractCssValue,
updateCssValue,
parseValueWithUnit
};
export default cssParsingUtils; export default cssParsingUtils;