Start work on inline tools
This commit is contained in:
parent
e3ddcefb30
commit
0fcb0d03a0
33 changed files with 2013 additions and 169 deletions
|
@ -21,3 +21,7 @@ export default {
|
|||
view: defineAsyncComponent(() => import('./view')),
|
||||
} as IBlockDefinition<any>;
|
||||
```
|
||||
|
||||
## Go by example
|
||||
|
||||
As Schlechtenburg is still in active development, it's good to check out the official blocks to see what they look like.
|
||||
|
|
|
@ -164,6 +164,7 @@ export const SbBlock = defineComponent({
|
|||
eventRemoveSelf={props.eventRemoveSelf}
|
||||
eventActivatePrevious={props.eventActivatePrevious}
|
||||
eventActivateNext={props.eventActivateNext}
|
||||
data-sb-block--content
|
||||
|
||||
{...{
|
||||
onClick: ($event: MouseEvent) => {
|
||||
|
|
76
packages/core/lib/components/Contenteditable.tsx
Normal file
76
packages/core/lib/components/Contenteditable.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import {
|
||||
ref,
|
||||
Ref,
|
||||
h,
|
||||
defineComponent,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
PropType,
|
||||
watchEffect,
|
||||
} from 'vue';
|
||||
import { useInline } from '../inline';
|
||||
|
||||
export const SbContenteditable = defineComponent({
|
||||
name: 'sb-contenteditable',
|
||||
|
||||
props: {
|
||||
inputRef: {
|
||||
type: (null as unknown) as PropType<Ref<HTMLElement|null>>,
|
||||
default: ref(null),
|
||||
},
|
||||
tag: { type: String, default: 'div' },
|
||||
|
||||
value: { type: String, default: '' },
|
||||
|
||||
onValueChange: {
|
||||
type: (null as unknown) as PropType<(value: string) => void>,
|
||||
default: (_:string) => {},
|
||||
},
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const {
|
||||
registerClient,
|
||||
unregisterClient,
|
||||
} = useInline();
|
||||
const onKeyup = (event: KeyboardEvent) => {
|
||||
if (event.code !== 'Backspace' && event.code !== 'Delete') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.inputRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const textContent = props.inputRef.value.textContent;
|
||||
if (textContent === '') {
|
||||
props.inputRef.value.innerHTML = '';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
props.inputRef.value?.focus();
|
||||
registerClient(props.inputRef.value!);
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.inputRef.value && props.inputRef.value.innerHTML != props.value) {
|
||||
props.inputRef.value.innerHTML = props.value;
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unregisterClient(props.inputRef.value!);
|
||||
});
|
||||
|
||||
return () => h(props.tag, {
|
||||
class: 'sb-contenteditable',
|
||||
contenteditable: 'true',
|
||||
ref: props.inputRef,
|
||||
onKeyup,
|
||||
onInput: () => {
|
||||
props.onValueChange(props.inputRef.value?.innerHTML || '');
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
|
@ -2,6 +2,7 @@ import {
|
|||
defineComponent,
|
||||
provide,
|
||||
shallowReactive,
|
||||
shallowRef,
|
||||
ref,
|
||||
watch,
|
||||
computed,
|
||||
|
@ -15,6 +16,8 @@ import {
|
|||
IBlockLibrary,
|
||||
ITreeNode,
|
||||
OnUpdateBlockCb,
|
||||
IInlineToolDefinition,
|
||||
IInlineToolLibrary,
|
||||
} from '../types';
|
||||
import { model } from '../block-helpers';
|
||||
import { SymMode, SbMode } from '../mode';
|
||||
|
@ -26,12 +29,19 @@ import {
|
|||
} from '../use-block-tree';
|
||||
import { SymEditorDimensions, useResizeObserver } from '../use-resize-observer';
|
||||
import { SymActiveBlock } from '../use-activation';
|
||||
import { SymRootElement } from '../use-root-element';
|
||||
import {
|
||||
SbInlineToolbar,
|
||||
SymInlineToolbarClients,
|
||||
SymInlineToolLibrary,
|
||||
} from '../inline';
|
||||
|
||||
import { SbMainMenu } from './MainMenu';
|
||||
import { SbBlock } from './Block';
|
||||
|
||||
export interface ISbMainProps {
|
||||
availableBlocks: IBlockDefinition<any>[];
|
||||
availableInlineTools: IInlineToolDefinition[];
|
||||
block: IBlockData<any>;
|
||||
eventUpdate: OnUpdateBlockCb;
|
||||
mode: SbMode;
|
||||
|
@ -49,6 +59,10 @@ export const SbMain = defineComponent({
|
|||
type: Array as PropType<IBlockDefinition<any>[]>,
|
||||
default: () => [],
|
||||
},
|
||||
availableInlineTools: {
|
||||
type: Array as PropType<IInlineToolDefinition[]>,
|
||||
default: () => [],
|
||||
},
|
||||
block: {
|
||||
type: Object as PropType<IBlockData<any>>,
|
||||
required: true,
|
||||
|
@ -72,6 +86,10 @@ export const SbMain = defineComponent({
|
|||
setup(props: ISbMainProps) {
|
||||
const el: Ref<null|HTMLElement> = ref(null);
|
||||
useResizeObserver(el, SymEditorDimensions);
|
||||
provide(SymRootElement, el);
|
||||
|
||||
const inlineClients = shallowRef([]);
|
||||
provide(SymInlineToolbarClients, inlineClients);
|
||||
|
||||
const mode = ref(props.mode);
|
||||
provide(SymMode, mode);
|
||||
|
@ -99,9 +117,16 @@ export const SbMain = defineComponent({
|
|||
{},
|
||||
),
|
||||
});
|
||||
|
||||
provide(SymBlockLibrary, blockLibrary);
|
||||
|
||||
const inlineToolLibrary: IInlineToolLibrary = shallowReactive({
|
||||
...props.availableInlineTools.reduce(
|
||||
(tools: IInlineToolLibrary, tool: IInlineToolDefinition) => ({ ...tools, [tool.name]: tool }),
|
||||
{},
|
||||
),
|
||||
});
|
||||
provide(SymInlineToolLibrary, inlineToolLibrary);
|
||||
|
||||
return () => (
|
||||
<div
|
||||
class={classes.value}
|
||||
|
@ -123,6 +148,7 @@ export const SbMain = defineComponent({
|
|||
block={props.block}
|
||||
eventUpdate={props.eventUpdate}
|
||||
/>
|
||||
<SbInlineToolbar />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
export * from './mode';
|
||||
export * from './types';
|
||||
|
||||
export * from './inline';
|
||||
|
||||
export * from './block-helpers';
|
||||
|
||||
export * from './use-activation';
|
||||
|
@ -8,10 +10,14 @@ export * from './use-dynamic-blocks';
|
|||
export * from './use-resize-observer';
|
||||
|
||||
export * from './components/Main';
|
||||
|
||||
export * from './components/Block';
|
||||
export * from './components/BlockPicker';
|
||||
export * from './components/BlockOrdering';
|
||||
export * from './components/BlockPlaceholder';
|
||||
|
||||
export * from './components/Contenteditable';
|
||||
|
||||
export * from './components/Toolbar';
|
||||
export * from './components/Button';
|
||||
export * from './components/Select';
|
||||
|
|
12
packages/core/lib/inline/InlineToolbar.scss
Normal file
12
packages/core/lib/inline/InlineToolbar.scss
Normal file
|
@ -0,0 +1,12 @@
|
|||
.sb-inline-toolbar {
|
||||
position: absolute;
|
||||
background-color: var(--bg);
|
||||
height: 2rem;
|
||||
min-width: 2rem;
|
||||
display: flex;
|
||||
z-index: var(--z-toolbar);
|
||||
|
||||
&_hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
115
packages/core/lib/inline/InlineToolbar.tsx
Normal file
115
packages/core/lib/inline/InlineToolbar.tsx
Normal file
|
@ -0,0 +1,115 @@
|
|||
import {
|
||||
ref,
|
||||
Ref,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
defineComponent,
|
||||
computed,
|
||||
} from 'vue';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useRootElement } from '../use-root-element';
|
||||
import { useInline } from './use-inline';
|
||||
import {
|
||||
getSelection,
|
||||
getAnchorElementForSelection,
|
||||
getRangeFromSelection,
|
||||
getRect,
|
||||
} from './selection';
|
||||
|
||||
import './InlineToolbar.scss';
|
||||
|
||||
export const SbInlineToolbar = defineComponent({
|
||||
name: 'sb-inlinetoolbar',
|
||||
|
||||
setup() {
|
||||
const { rootElement } = useRootElement();
|
||||
const {
|
||||
clients,
|
||||
getAllTools,
|
||||
} = useInline();
|
||||
|
||||
const allTools = computed(() => getAllTools());
|
||||
|
||||
const selectionRect: Ref<DOMRect|null>= ref(null);
|
||||
const updateSelectionRect = () => {
|
||||
const selection = getSelection();
|
||||
if (!selection) {
|
||||
console.warn('Could not get selection');
|
||||
return;
|
||||
}
|
||||
selectionRect.value = getRect(selection);
|
||||
};
|
||||
|
||||
const showing: Ref<boolean> = ref(false);
|
||||
|
||||
const show = () => {
|
||||
showing.value = true;
|
||||
updateSelectionRect();
|
||||
};
|
||||
const hide = () => { showing.value = false; };
|
||||
|
||||
const style = computed(() => {
|
||||
const rootRect = rootElement.value?.getBoundingClientRect();
|
||||
const x = (selectionRect.value?.x || 0)
|
||||
- (rootRect?.left || 0);
|
||||
const y = (selectionRect.value?.y || 0)
|
||||
+ (selectionRect.value?.height || 0)
|
||||
- (rootRect?.top || 0);
|
||||
return {
|
||||
left: Math.floor(x) + 'px',
|
||||
top: Math.floor(y) + 'px',
|
||||
};
|
||||
});
|
||||
|
||||
const classes = computed(() => ({
|
||||
'sb-inline-toolbar': true,
|
||||
'sb-inline-toolbar_hidden': !showing.value,
|
||||
}));
|
||||
|
||||
const onSelectionChanged = debounce(() => {
|
||||
const selection = getSelection();
|
||||
if (!selection) {
|
||||
console.warn('Could not get selection');
|
||||
return
|
||||
}
|
||||
|
||||
const range = getRangeFromSelection(selection);
|
||||
// If we're not selecting anything, bail
|
||||
if (!range || range.endOffset === range.startOffset) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const focusedElement = getAnchorElementForSelection(selection);
|
||||
if (!focusedElement) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
// If new selection is not in registered clients, close it
|
||||
if (!clients.value.find(client => client === focusedElement)) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
show();
|
||||
}, 50);
|
||||
|
||||
onMounted(() => {
|
||||
rootElement.value?.addEventListener('click', close);
|
||||
document.addEventListener('selectionchange', onSelectionChanged, true);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
rootElement.value?.removeEventListener('click', close);
|
||||
document.removeEventListener('selectionchange', onSelectionChanged);
|
||||
});
|
||||
|
||||
return () => allTools.value.length
|
||||
? <div
|
||||
style={style.value}
|
||||
class={classes.value}
|
||||
>{allTools.value.map(tool => tool.ui)}</div>
|
||||
: null;
|
||||
},
|
||||
});
|
37
packages/core/lib/inline/dom.ts
Normal file
37
packages/core/lib/inline/dom.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright notice:
|
||||
*
|
||||
* Large parts of this file are heavily inspired if not downright copied from editor.js,
|
||||
* copyright MIT.
|
||||
* https://editorjs.io/
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if object is DOM node
|
||||
*
|
||||
* @param {*} node - object to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const isElement = (node: any): node is Element => {
|
||||
if (node instanceof Number) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return node && node.nodeType && node.nodeType === Node.ELEMENT_NODE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if object is DocumentFragment node
|
||||
*
|
||||
* @param {object} node - object to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const isFragment = (node: any): node is DocumentFragment => {
|
||||
if (node instanceof Number) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return node && node.nodeType && node.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
|
||||
}
|
4
packages/core/lib/inline/index.ts
Normal file
4
packages/core/lib/inline/index.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export * from './InlineToolbar';
|
||||
export * from './use-inline';
|
||||
export * from './selection';
|
||||
export * from './dom';
|
93
packages/core/lib/inline/selection.ts
Normal file
93
packages/core/lib/inline/selection.ts
Normal file
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright notice:
|
||||
*
|
||||
* Large parts of this file are heavily inspired if not downright copied from editor.js,
|
||||
* copyright MIT.
|
||||
* https://editorjs.io/
|
||||
*/
|
||||
|
||||
import { isElement } from './dom';
|
||||
|
||||
export const getSelection = globalThis.getSelection ? globalThis.getSelection : () => null;
|
||||
|
||||
/**
|
||||
* Returns range from passed Selection object
|
||||
*
|
||||
* @param selection - Selection object to get Range from
|
||||
*/
|
||||
export const getRangeFromSelection = (selection: Selection): Range|null => {
|
||||
return selection && selection.rangeCount ? selection.getRangeAt(0) : null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns selected anchor element
|
||||
*
|
||||
* @returns {Element|null}
|
||||
*/
|
||||
export const getAnchorElementForSelection = (selection: Selection): Element | null => {
|
||||
const anchorNode = selection.anchorNode;
|
||||
|
||||
if (!anchorNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isElement(anchorNode)) {
|
||||
return anchorNode.parentElement;
|
||||
} else {
|
||||
return anchorNode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates position and size of selected text
|
||||
*
|
||||
* @returns {DOMRect}
|
||||
*/
|
||||
export const getRect = (selection: Selection): DOMRect => {
|
||||
const defaultRect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
} as DOMRect;
|
||||
|
||||
if (selection.rangeCount === null || isNaN(selection.rangeCount)) {
|
||||
console.warn('Method SelectionUtils.rangeCount is not supported');
|
||||
return defaultRect;
|
||||
}
|
||||
|
||||
if (selection.rangeCount === 0) {
|
||||
return defaultRect;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0).cloneRange() as Range;
|
||||
|
||||
let rect = { ...defaultRect };
|
||||
|
||||
if (range.getBoundingClientRect) {
|
||||
rect = range.getBoundingClientRect() as DOMRect;
|
||||
}
|
||||
// Fall back to inserting a temporary element
|
||||
if (rect.x === 0 && rect.y === 0) {
|
||||
const span = document.createElement('span');
|
||||
|
||||
if (span.getBoundingClientRect) {
|
||||
// Ensure span has dimensions and position by
|
||||
// adding a zero-width space character
|
||||
span.appendChild(document.createTextNode('\u200b'));
|
||||
range.insertNode(span);
|
||||
rect = span.getBoundingClientRect() as DOMRect;
|
||||
|
||||
const spanParent = span.parentNode;
|
||||
if (spanParent) {
|
||||
spanParent.removeChild(span);
|
||||
|
||||
// Glue any broken text nodes back together
|
||||
spanParent.normalize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rect;
|
||||
};
|
49
packages/core/lib/inline/use-inline.ts
Normal file
49
packages/core/lib/inline/use-inline.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import {
|
||||
ref,
|
||||
Ref,
|
||||
inject,
|
||||
reactive,
|
||||
} from 'vue';
|
||||
import { IInlineToolLibrary } from '../types';
|
||||
|
||||
export const SymInlineToolbarClients = Symbol('Schlechtenburg inline toolbar client elements');
|
||||
export const SymInlineToolLibrary = Symbol('Schlechtenburg inline tool library');
|
||||
|
||||
export const useInline = () => {
|
||||
const clients: Ref<HTMLElement[]> = inject(SymInlineToolbarClients, ref([]));
|
||||
const tools: IInlineToolLibrary = inject(SymInlineToolLibrary, reactive({}));
|
||||
const getTool = (name: string) => tools[name];
|
||||
const getAllTools = () => Object.keys(tools).map(name => tools[name]);
|
||||
|
||||
const setClients = (newClients: HTMLElement[]) => {
|
||||
clients.value = newClients;
|
||||
};
|
||||
|
||||
const clientIsRegistered = (el: HTMLElement) => !!clients.value.find(cl => cl === el);
|
||||
|
||||
const registerClient = (el: HTMLElement) => {
|
||||
if (clientIsRegistered(el)) {
|
||||
console.warn('Not reregistering toolbar client that is already registered:', el);
|
||||
return;
|
||||
}
|
||||
|
||||
setClients([
|
||||
...clients.value,
|
||||
el,
|
||||
]);
|
||||
};
|
||||
|
||||
const unregisterClient = (el: HTMLElement) => {
|
||||
setClients(clients.value.filter(cl => cl !== el));
|
||||
};
|
||||
|
||||
return {
|
||||
tools,
|
||||
getTool,
|
||||
getAllTools,
|
||||
|
||||
clients,
|
||||
registerClient,
|
||||
unregisterClient,
|
||||
};
|
||||
};
|
|
@ -170,3 +170,22 @@ export interface IBlockDefinition<T> {
|
|||
export interface IBlockLibrary {
|
||||
[name: string]: IBlockDefinition<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Any Block that you create
|
||||
*/
|
||||
export interface IInlineToolDefinition {
|
||||
name: string;
|
||||
ui: Component<any>;
|
||||
surround: (range: Range) => void;
|
||||
checkState: (selection: Selection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schlechtenburg maintains a library of inline tools that are available
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export interface IInlineToolLibrary {
|
||||
[name: string]: IInlineToolDefinition;
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from 'vue';
|
||||
|
||||
export const SymActiveBlock = Symbol('Schlechtenburg active block');
|
||||
export function useActivation(currentBlockId: string|null = null) {
|
||||
export const useActivation = (currentBlockId: string|null = null) => {
|
||||
const activeBlockId: Ref<string|null> = inject(SymActiveBlock, ref(null));
|
||||
|
||||
const isActive = computed(() => activeBlockId.value === currentBlockId);
|
||||
|
@ -46,4 +46,4 @@ export function useActivation(currentBlockId: string|null = null) {
|
|||
deactivate,
|
||||
requestActivation,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
@ -10,14 +10,12 @@ import {
|
|||
ITreeNode,
|
||||
IBlockData,
|
||||
} from './types';
|
||||
import { useDynamicBlocks } from './use-dynamic-blocks';
|
||||
import { SbMode } from './mode';
|
||||
|
||||
export const SymBlockTree= Symbol('Schlechtenburg block tree');
|
||||
export const SymBlockTreeRegister = Symbol('Schlechtenburg block tree register');
|
||||
export const SymBlockTreeUnregister = Symbol('Schlechtenburg block tree unregister');
|
||||
|
||||
export function useBlockTree() {
|
||||
export const useBlockTree = () => {
|
||||
const blockTree: Ref<ITreeNode|null> = inject(SymBlockTree, ref(null));
|
||||
const registerWithParent = inject(SymBlockTreeRegister, (_b: ITreeNode, _i: number) => {});
|
||||
const unregisterWithParent = inject(SymBlockTreeUnregister, (_b: ITreeNode) => {});
|
||||
|
@ -52,8 +50,6 @@ export function useBlockTree() {
|
|||
self.children = self.children.filter((child: ITreeNode) => child.id !== id);
|
||||
});
|
||||
|
||||
const { mode } = useDynamicBlocks();
|
||||
|
||||
const register = (block: IBlockData<any>, index: number = 0) => {
|
||||
if (!block.id) {
|
||||
throw new Error(`Cannot register a block without an id: ${JSON.stringify(block)}`);
|
||||
|
@ -77,4 +73,4 @@ export function useBlockTree() {
|
|||
blockTree,
|
||||
register,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
@ -16,7 +16,7 @@ interface BlockRect {
|
|||
|
||||
export const SymBlockDimensions = Symbol('Schlechtenburg block dimensions');
|
||||
export const SymEditorDimensions = Symbol('Schlechtenburg editor dimensions');
|
||||
export function useResizeObserver(el: Ref<null|HTMLElement>, symbol: symbol) {
|
||||
export const useResizeObserver = (el: Ref<null|HTMLElement>, symbol: symbol) => {
|
||||
const dimensions: Ref<null|BlockRect> = ref(null);
|
||||
provide(symbol, dimensions);
|
||||
const triggerSizeCalculation = () => {
|
||||
|
@ -47,7 +47,7 @@ export function useResizeObserver(el: Ref<null|HTMLElement>, symbol: symbol) {
|
|||
})
|
||||
|
||||
return { triggerSizeCalculation, dimensions };
|
||||
}
|
||||
};
|
||||
|
||||
export function useBlockSizing() {
|
||||
const editorDimensions: Ref<BlockRect|null> = inject(SymEditorDimensions, ref(null));
|
||||
|
|
11
packages/core/lib/use-root-element.ts
Normal file
11
packages/core/lib/use-root-element.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import {
|
||||
Ref,
|
||||
ref,
|
||||
inject,
|
||||
} from 'vue';
|
||||
|
||||
export const SymRootElement = Symbol('Schlechtenburg root element');
|
||||
export const useRootElement = () => {
|
||||
const rootElement: Ref<HTMLElement|null> = inject(SymRootElement, ref(null));
|
||||
return { rootElement };
|
||||
};
|
|
@ -43,7 +43,7 @@ body {
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&--admin-nav {
|
||||
&--edit-nav {
|
||||
z-index: 100;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
@ -62,7 +62,7 @@ body {
|
|||
@media screen and (min-width: 1000px) {
|
||||
--ex-nav-width: var(--ex-nav-expanded-width);
|
||||
|
||||
&--admin-nav {
|
||||
&--edit-nav {
|
||||
position: unset;
|
||||
width: unset;
|
||||
flex-basis: var(--ex-nav-width);
|
||||
|
|
|
@ -3,14 +3,14 @@ import { NuxtPage } from '#components';
|
|||
|
||||
import './app.scss';
|
||||
|
||||
const AdminNav = defineAsyncComponent(() => import('~~/components/admin/Nav'));
|
||||
const AdminNav = defineAsyncComponent(() => import('~~/components/_/Nav'));
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const { me } = useMe();
|
||||
return () => (
|
||||
<div class="ex-app">
|
||||
{me.value ? <AdminNav class="ex-app--admin-nav" /> : null}
|
||||
{me.value ? <AdminNav class="ex-app--edit-nav" /> : null}
|
||||
<NuxtPage class="ex-app--page" />
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
.ex-admin-nav {
|
||||
.ex-edit-nav {
|
||||
background-color: white;
|
||||
width: var(--ex-nav-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
transition: width 0.2s ease;
|
||||
|
||||
.icon {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
&_expanded {
|
||||
width: 300px;
|
||||
|
@ -54,24 +58,17 @@
|
|||
width: calc(100% - 60px);
|
||||
overflow: hidden;
|
||||
margin: 0px;
|
||||
margin-left: 16px;
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
position: absolute;
|
||||
left: calc((var(--ex-nav-width) / 2) - 8px);
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&_expanded &--menu-item {
|
||||
&-title {
|
||||
//width: calc(100% - (32px + 24px));
|
||||
opacity: 1;
|
||||
&-action {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
&-title {
|
||||
opacity: 1;
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
|
67
packages/example-site/components/_/Nav.tsx
Normal file
67
packages/example-site/components/_/Nav.tsx
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { defineComponent } from 'vue';
|
||||
import { NuxtLink } from '#components';
|
||||
|
||||
import './Nav.scss';
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const { setMe } = useMe();
|
||||
|
||||
const expanded = useState(() => false);
|
||||
const toggle = () => {
|
||||
expanded.value = !expanded.value;
|
||||
};
|
||||
|
||||
const classes = computed(() => ({
|
||||
'ex-edit-nav': true,
|
||||
'ex-edit-nav_expanded': expanded.value,
|
||||
}));
|
||||
|
||||
const logout = () => {
|
||||
setMe(null);
|
||||
useGqlToken({
|
||||
token: null,
|
||||
config: { type: 'Bearer' },
|
||||
});
|
||||
};
|
||||
|
||||
return () => (
|
||||
<nav class={classes.value}>
|
||||
<button
|
||||
class="ex-edit-nav--toggle"
|
||||
type="button"
|
||||
onClick={() => toggle()}
|
||||
aria-label="Toggle"
|
||||
>
|
||||
{expanded.value
|
||||
? <Icon name="icon-park-solid:expand-right" class="ex-edit-nav--menu-item-icon" />
|
||||
: <Icon name="icon-park-solid:expand-left" class="ex-edit-nav--menu-item-icon" />}
|
||||
</button>
|
||||
<ul class="ex-edit-nav--menu">
|
||||
<li class="ex-edit-nav--menu-item">
|
||||
<NuxtLink
|
||||
class="ex-edit-nav--menu-item-action"
|
||||
to="/"
|
||||
>
|
||||
<Icon name="mdi:web" class="ex-edit-nav--menu-item-icon" />
|
||||
<span class="ex-edit-nav--menu-item-title">Website</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
|
||||
<li class="ex-edit-nav--menu-spacer"></li>
|
||||
|
||||
<li class="ex-edit-nav--menu-item">
|
||||
<button
|
||||
type="button"
|
||||
class="ex-edit-nav--menu-item-action"
|
||||
onClick={() => logout()}
|
||||
>
|
||||
<Icon name="mdi:logout" class="ex-edit-nav--menu-item-icon" />
|
||||
<span class="ex-edit-nav--menu-item-title">Logout</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -1,73 +0,0 @@
|
|||
import { defineComponent } from 'vue';
|
||||
import { NuxtLink } from '#components';
|
||||
|
||||
import './Nav.scss';
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const { setMe } = useMe();
|
||||
|
||||
const expanded = useState(() => false);
|
||||
const toggle = () => {
|
||||
expanded.value = !expanded.value;
|
||||
};
|
||||
|
||||
const classes = computed(() => ({
|
||||
'ex-admin-nav': true,
|
||||
'ex-admin-nav_expanded': expanded.value,
|
||||
}));
|
||||
|
||||
const logout = () => {
|
||||
setMe(null);
|
||||
useGqlToken({
|
||||
token: null,
|
||||
config: { type: 'Bearer' },
|
||||
});
|
||||
};
|
||||
|
||||
return () => (
|
||||
<nav class={classes.value}>
|
||||
<button
|
||||
class="ex-admin-nav--toggle"
|
||||
type="button"
|
||||
onClick={() => toggle()}
|
||||
aria-label="Toggle"
|
||||
>
|
||||
<font-awesome-icon
|
||||
icon={`fa-solid fa-arrow-${expanded.value ? 'left' : 'right'}`}
|
||||
/>
|
||||
</button>
|
||||
<ul class="ex-admin-nav--menu">
|
||||
<li class="ex-admin-nav--menu-item">
|
||||
<NuxtLink
|
||||
class="ex-admin-nav--menu-item-action"
|
||||
to="/"
|
||||
>
|
||||
<font-awesome-icon
|
||||
class="ex-admin-nav--menu-item-icon"
|
||||
icon="fa-solid fa-home"
|
||||
/>
|
||||
<span class="ex-admin-nav--menu-item-title">Website</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
|
||||
<li class="ex-admin-nav--menu-spacer"></li>
|
||||
|
||||
<li class="ex-admin-nav--menu-item">
|
||||
<button
|
||||
type="button"
|
||||
class="ex-admin-nav--menu-item-action"
|
||||
onClick={() => logout()}
|
||||
>
|
||||
<font-awesome-icon
|
||||
class="ex-admin-nav--menu-item-icon"
|
||||
icon="fa-solid fa-right-from-bracket"
|
||||
/>
|
||||
<span class="ex-admin-nav--menu-item-title">Logout</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
},
|
||||
});
|
|
@ -3,6 +3,7 @@ import { defineNuxtConfig } from 'nuxt/config';
|
|||
export default defineNuxtConfig({
|
||||
modules: [
|
||||
'nuxt-graphql-client',
|
||||
'nuxt-icon',
|
||||
],
|
||||
|
||||
runtimeConfig: {
|
||||
|
|
49
packages/example-site/package-lock.json
generated
49
packages/example-site/package-lock.json
generated
|
@ -812,32 +812,6 @@
|
|||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@fortawesome/fontawesome-common-types": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.2.1.tgz",
|
||||
"integrity": "sha512-Sz07mnQrTekFWLz5BMjOzHl/+NooTdW8F8kDQxjWwbpOJcnoSg4vUDng8d/WR1wOxM0O+CY9Zw0nR054riNYtQ=="
|
||||
},
|
||||
"@fortawesome/fontawesome-svg-core": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.2.1.tgz",
|
||||
"integrity": "sha512-HELwwbCz6C1XEcjzyT1Jugmz2NNklMrSPjZOWMlc+ZsHIVk+XOvOXLGGQtFBwSyqfJDNgRq4xBCwWOaZ/d9DEA==",
|
||||
"requires": {
|
||||
"@fortawesome/fontawesome-common-types": "6.2.1"
|
||||
}
|
||||
},
|
||||
"@fortawesome/free-solid-svg-icons": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.2.1.tgz",
|
||||
"integrity": "sha512-oKuqrP5jbfEPJWTij4sM+/RvgX+RMFwx3QZCZcK9PrBDgxC35zuc7AOFsyMjMd/PIFPeB2JxyqDr5zs/DZFPPw==",
|
||||
"requires": {
|
||||
"@fortawesome/fontawesome-common-types": "6.2.1"
|
||||
}
|
||||
},
|
||||
"@fortawesome/vue-fontawesome": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.0.2.tgz",
|
||||
"integrity": "sha512-xHVtVY8ASUeEvgcA/7vULUesENhD+pi/EirRHdMBqooHlXBqK+yrV6d8tUye1m5UKQKVgYAHMhUBfOnoiwvc8Q=="
|
||||
},
|
||||
"@graphql-codegen/cli": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/@graphql-codegen/cli/-/cli-2.16.1.tgz",
|
||||
|
@ -2033,6 +2007,19 @@
|
|||
"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz",
|
||||
"integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="
|
||||
},
|
||||
"@iconify/types": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
|
||||
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="
|
||||
},
|
||||
"@iconify/vue": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-4.0.2.tgz",
|
||||
"integrity": "sha512-LRp+mYh8N0bcX4lustHtI5o1aEoio9HN3/19uIzVOvI78qopKBjzsDK5hkEZYDSc6+LKG8hfLxTxpW8CejXGZg==",
|
||||
"requires": {
|
||||
"@iconify/types": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"@ioredis/commands": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
|
||||
|
@ -2165,7 +2152,6 @@
|
|||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.0.0.tgz",
|
||||
"integrity": "sha512-7ZsOLt5s9a0ZleAIzmoD70JwkZf5ti6bDdxl6f8ew7Huxz+ni/oRfTPTX9TrORXsgW5CvDt6Q9M7IJNPkAN/Iw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@nuxt/schema": "3.0.0",
|
||||
"c12": "^1.0.1",
|
||||
|
@ -6713,6 +6699,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"nuxt-icon": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/nuxt-icon/-/nuxt-icon-0.1.8.tgz",
|
||||
"integrity": "sha512-oPFlLOZCy80MN+hf49+mBkOIHWVF3sOqZREQZw3qD0N6wGlR15QeRQtKQC8qGeQcc+xvpLQm0GvrdJ8FxFOPYg==",
|
||||
"requires": {
|
||||
"@iconify/vue": "^4.0.1",
|
||||
"@nuxt/kit": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
|
|
@ -13,9 +13,6 @@
|
|||
"nuxt": "3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.2.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.2",
|
||||
"@graphql-codegen/cli": "^2.16.1",
|
||||
"@schlechtenburg/core": "^0.0.0",
|
||||
"@schlechtenburg/heading": "^0.0.0",
|
||||
|
@ -24,6 +21,7 @@
|
|||
"@schlechtenburg/paragraph": "^0.0.0",
|
||||
"@schlechtenburg/style": "^0.0.0",
|
||||
"event-target-polyfill": "^0.0.3",
|
||||
"nuxt-graphql-client": "^0.2.23"
|
||||
"nuxt-graphql-client": "^0.2.23",
|
||||
"nuxt-icon": "^0.1.8"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import {
|
||||
faRightFromBracket,
|
||||
faHome,
|
||||
faArrowRight,
|
||||
faArrowLeft,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
/* add icons to the library */
|
||||
library.add(faRightFromBracket);
|
||||
library.add(faArrowRight);
|
||||
library.add(faArrowLeft);
|
||||
library.add(faHome);
|
||||
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
nuxtApp.vueApp.component('font-awesome-icon', FontAwesomeIcon);
|
||||
})
|
0
packages/italic/lib/ItalicToolUI.tsx
Normal file
0
packages/italic/lib/ItalicToolUI.tsx
Normal file
8
packages/italic/lib/index.ts
Normal file
8
packages/italic/lib/index.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { defineAsyncComponent } from 'vue';
|
||||
|
||||
export const name = 'sb-italic';
|
||||
|
||||
export default {
|
||||
name,
|
||||
ui: defineAsyncComponent(() => import('./ui')),
|
||||
};
|
28
packages/italic/lib/ui.tsx
Normal file
28
packages/italic/lib/ui.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { defineComponent } from 'vue';
|
||||
import {
|
||||
SbButton,
|
||||
getSelection,
|
||||
getRangeFromSelection,
|
||||
} from '@schlechtenburg/core';
|
||||
import { surround } from './util';
|
||||
|
||||
export default defineComponent({
|
||||
async setup() {
|
||||
return () => <SbButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const selection = getSelection();
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = getRangeFromSelection(selection);
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
surround(range);
|
||||
}}
|
||||
>i</SbButton>;
|
||||
},
|
||||
});
|
25
packages/italic/lib/util.ts
Normal file
25
packages/italic/lib/util.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import {
|
||||
getSelection,
|
||||
getRangeFromSelection,
|
||||
} from '@schlechtenburg/core';
|
||||
|
||||
/**
|
||||
* Wrap range with <i> tag
|
||||
*/
|
||||
export const surround = (range: Range) => {
|
||||
range.surroundContents(document.createElement('i'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check selection and set activated state to button if there are <i> tag
|
||||
*/
|
||||
export const checkState = (): boolean => {
|
||||
const selection = getSelection();
|
||||
const range = getRangeFromSelection(selection);
|
||||
|
||||
console.log(range);
|
||||
|
||||
const isActive = document.queryCommandState('italic');
|
||||
|
||||
return isActive;
|
||||
}
|
1334
packages/italic/package-lock.json
generated
Normal file
1334
packages/italic/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
36
packages/italic/package.json
Normal file
36
packages/italic/package.json
Normal file
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "@schlechtenburg/italic",
|
||||
"version": "0.0.0",
|
||||
"description": "> TODO: description",
|
||||
"author": "Benjamin Bädorf <hello@benjaminbaedorf.eu>",
|
||||
"homepage": "",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"main": "lib/index.ts",
|
||||
"scripts": {
|
||||
"typecheck": "vuedx-typecheck --no-pretty ./lib",
|
||||
"test": "echo \"Error: run tests from root\" && exit 1"
|
||||
},
|
||||
"directories": {
|
||||
"lib": "lib",
|
||||
"test": "__tests__"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"docs"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@git.b12f.io:b12f/schlechtenburg.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"@schlechtenburg/core": "^0.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vuedx/typecheck": "^0.6.3",
|
||||
"@vuedx/typescript-plugin-vue": "^0.6.3",
|
||||
"vue": "^3.2.31"
|
||||
}
|
||||
}
|
|
@ -14,6 +14,8 @@ import {
|
|||
SbToolbar,
|
||||
SbSelect,
|
||||
generateBlockId,
|
||||
SbContenteditable,
|
||||
getSelection,
|
||||
} from '@schlechtenburg/core';
|
||||
import { isEmptyContentEditable } from './contenteditable';
|
||||
import {
|
||||
|
@ -77,21 +79,11 @@ export default defineComponent({
|
|||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
focusInput();
|
||||
if (inputEl.value) {
|
||||
inputEl.value.innerHTML = localData.value;
|
||||
}
|
||||
});
|
||||
|
||||
watch(isActive, focusInput);
|
||||
|
||||
watch(() => props.data, () => {
|
||||
localData.value = props.data.value;
|
||||
localData.align = props.data.align;
|
||||
if (inputEl.value) {
|
||||
inputEl.value.innerHTML = localData.value;
|
||||
}
|
||||
});
|
||||
|
||||
const onTextUpdate = ($event: Event) => {
|
||||
|
@ -136,7 +128,7 @@ export default defineComponent({
|
|||
activate(id);
|
||||
|
||||
$event.preventDefault();
|
||||
}
|
||||
}window
|
||||
};
|
||||
|
||||
const onKeyup = ($event: KeyboardEvent) => {
|
||||
|
@ -144,7 +136,7 @@ export default defineComponent({
|
|||
props.eventRemoveSelf();
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
const selection = getSelection();
|
||||
const node = selection?.focusNode;
|
||||
const childNodes = Array.from(inputEl?.value?.childNodes || []);
|
||||
const index = node ? childNodes.indexOf(node as ChildNode) : -1;
|
||||
|
@ -174,16 +166,22 @@ export default defineComponent({
|
|||
<option>right</option>
|
||||
</SbSelect>
|
||||
</SbToolbar>
|
||||
<p
|
||||
<SbContenteditable
|
||||
tag="p"
|
||||
class="sb-paragraph__input"
|
||||
ref={inputEl}
|
||||
contenteditable
|
||||
onInput={onTextUpdate}
|
||||
|
||||
inputRef={inputEl}
|
||||
|
||||
value={localData.value}
|
||||
onValueChange={(value: string) => {
|
||||
localData.value = value;
|
||||
}}
|
||||
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeydown={onKeydown}
|
||||
onKeyup={onKeyup}
|
||||
></p>
|
||||
></SbContenteditable>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
Loading…
Add table
Reference in a new issue