Compare commits

...

8 commits
master ... cms

133 changed files with 70291 additions and 109 deletions

View file

@ -11,6 +11,7 @@ import SbLayout from '@schlechtenburg/layout';
import SbHeading from '@schlechtenburg/heading'; import SbHeading from '@schlechtenburg/heading';
import SbParagraph from '@schlechtenburg/paragraph'; import SbParagraph from '@schlechtenburg/paragraph';
import SbImage from '@schlechtenburg/image'; import SbImage from '@schlechtenburg/image';
import SbItalicTool from '@schlechtenburg/italic';
import exampleData from './example-data'; import exampleData from './example-data';
@ -38,6 +39,9 @@ export default defineComponent({
SbImage, SbImage,
SbParagraph, SbParagraph,
]} ]}
availableInlineTools={[
SbItalicTool,
]}
mode={activeTab.value as SbMode} mode={activeTab.value as SbMode}
eventUpdate={(data:IBlockData<any>) => { eventUpdate={(data:IBlockData<any>) => {
block.id = data.id; block.id = data.id;

View file

@ -21,3 +21,7 @@ export default {
view: defineAsyncComponent(() => import('./view')), view: defineAsyncComponent(() => import('./view')),
} as IBlockDefinition<any>; } 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.

View file

@ -23,6 +23,11 @@ html {
--interact: #3f9cff; --interact: #3f9cff;
--interact-lite: #3f9cff; --interact-lite: #3f9cff;
--info: var(--interact);
--success: green;
--warning: orange;
--error: red;
} }
body { body {

View file

@ -13,6 +13,7 @@ export default defineConfig({
'@schlechtenburg/heading': join(__dirname, '../packages/heading/lib/index.ts'), '@schlechtenburg/heading': join(__dirname, '../packages/heading/lib/index.ts'),
'@schlechtenburg/image': join(__dirname, '../packages/image/lib/index.ts'), '@schlechtenburg/image': join(__dirname, '../packages/image/lib/index.ts'),
'@schlechtenburg/layout': join(__dirname, '../packages/layout/lib/index.ts'), '@schlechtenburg/layout': join(__dirname, '../packages/layout/lib/index.ts'),
'@schlechtenburg/italic': join(__dirname, '../packages/italic/lib/index.ts'),
}, },
}, },
}); });

View file

@ -0,0 +1,16 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[{package.json,*.yml}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false

View file

@ -0,0 +1,6 @@
HOST=0.0.0.0
PORT=1337
APP_KEYS="toBeModified1,toBeModified2"
API_TOKEN_SALT=tobemodified
ADMIN_JWT_SECRET=tobemodified
JWT_SECRET=tobemodified

115
packages/cms/.gitignore vendored Normal file
View file

@ -0,0 +1,115 @@
############################
# OS X
############################
.DS_Store
.AppleDouble
.LSOverride
Icon
.Spotlight-V100
.Trashes
._*
############################
# Linux
############################
*~
############################
# Windows
############################
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msm
*.msp
############################
# Packages
############################
*.7z
*.csv
*.dat
*.dmg
*.gz
*.iso
*.jar
*.rar
*.tar
*.zip
*.com
*.class
*.dll
*.exe
*.o
*.seed
*.so
*.swo
*.swp
*.swn
*.swm
*.out
*.pid
############################
# Logs and databases
############################
.tmp
*.log
*.sql
*.sqlite
*.sqlite3
############################
# Misc.
############################
*#
ssl
.idea
nbproject
public/uploads/*
!public/uploads/.gitkeep
############################
# Node.js
############################
lib-cov
lcov.info
pids
logs
results
node_modules
.node_history
############################
# Tests
############################
testApp
coverage
############################
# Strapi
############################
.env
license.txt
exports
*.cache
dist
build
.strapi-updater.json

57
packages/cms/README.md Normal file
View file

@ -0,0 +1,57 @@
# 🚀 Getting started with Strapi
Strapi comes with a full featured [Command Line Interface](https://docs.strapi.io/developer-docs/latest/developer-resources/cli/CLI.html) (CLI) which lets you scaffold and manage your project in seconds.
### `dev`
Start your Strapi application with autoReload enabled. [Learn more](https://docs.strapi.io/developer-docs/latest/developer-resources/cli/CLI.html#strapi-develop)
```
npm run dev
# or
yarn dev
```
### `start`
Start your Strapi application with autoReload disabled. [Learn more](https://docs.strapi.io/developer-docs/latest/developer-resources/cli/CLI.html#strapi-start)
```
npm run start
# or
yarn start
```
### `build`
Build your admin panel. [Learn more](https://docs.strapi.io/developer-docs/latest/developer-resources/cli/CLI.html#strapi-build)
```
npm run build
# or
yarn build
```
## ⚙️ Deployment
Strapi gives you many possible deployment options for your project. Find the one that suits you on the [deployment section of the documentation](https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/deployment.html).
## 📚 Learn more
- [Resource center](https://strapi.io/resource-center) - Strapi resource center.
- [Strapi documentation](https://docs.strapi.io) - Official Strapi documentation.
- [Strapi tutorials](https://strapi.io/tutorials) - List of tutorials made by the core team and the community.
- [Strapi blog](https://docs.strapi.io) - Official Strapi blog containing articles made by the Strapi team and the community.
- [Changelog](https://strapi.io/changelog) - Find out about the Strapi product updates, new features and general improvements.
Feel free to check out the [Strapi GitHub repository](https://github.com/strapi/strapi). Your feedback and contributions are welcome!
## ✨ Community
- [Discord](https://discord.strapi.io) - Come chat with the Strapi community including the core team.
- [Forum](https://forum.strapi.io/) - Place to discuss, ask questions and find answers, show your Strapi project and get feedback or just talk with other Community members.
- [Awesome Strapi](https://github.com/strapi/awesome-strapi) - A curated list of awesome things related to Strapi.
---
<sub>🤫 Psst! [Strapi is hiring](https://strapi.io/careers).</sub>

View file

@ -0,0 +1,8 @@
export default ({ env }) => ({
auth: {
secret: env('ADMIN_JWT_SECRET'),
},
apiToken: {
salt: env('API_TOKEN_SALT'),
},
});

View file

@ -0,0 +1,7 @@
export default {
rest: {
defaultLimit: 25,
maxLimit: 100,
withCount: true,
},
};

View file

@ -0,0 +1,11 @@
import path from 'path';
export default ({ env }) => ({
connection: {
client: 'sqlite',
connection: {
filename: path.join(__dirname, '..', '..', env('DATABASE_FILENAME', '.tmp/data.db')),
},
useNullAsDefault: true,
},
});

View file

@ -0,0 +1,12 @@
export default [
'strapi::errors',
'strapi::security',
'strapi::cors',
'strapi::poweredBy',
'strapi::logger',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];

View file

@ -0,0 +1,7 @@
export default ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
app: {
keys: env.array('APP_KEYS'),
},
});

BIN
packages/cms/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

30093
packages/cms/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
packages/cms/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "@schlechtenburg/cms",
"private": true,
"version": "0.0.0",
"description": "A Strapi application",
"scripts": {
"dev": "strapi develop",
"start": "strapi start",
"build": "strapi build",
"strapi": "strapi"
},
"dependencies": {
"@strapi/plugin-graphql": "^4.5.3",
"@strapi/plugin-i18n": "4.5.3",
"@strapi/plugin-users-permissions": "4.5.3",
"@strapi/strapi": "4.5.3",
"better-sqlite3": "7.4.6"
},
"author": {
"name": "A Strapi developer"
},
"strapi": {
"uuid": "d70cca4c-887b-45a7-8b2d-afb95c49a0c8"
},
"engines": {
"node": ">=14.19.1 <=18.x.x",
"npm": ">=6.0.0"
},
"license": "MIT"
}

View file

@ -0,0 +1,3 @@
# To prevent search engines from seeing the site altogether, uncomment the next two lines:
# User-Agent: *
# Disallow: /

View file

View file

@ -0,0 +1,35 @@
export default {
config: {
locales: [
// 'ar',
// 'fr',
// 'cs',
// 'de',
// 'dk',
// 'es',
// 'he',
// 'id',
// 'it',
// 'ja',
// 'ko',
// 'ms',
// 'nl',
// 'no',
// 'pl',
// 'pt-BR',
// 'pt',
// 'ru',
// 'sk',
// 'sv',
// 'th',
// 'tr',
// 'uk',
// 'vi',
// 'zh-Hans',
// 'zh',
],
},
bootstrap(app) {
console.log(app);
},
};

View file

@ -0,0 +1,13 @@
{
"extends": "@strapi/typescript-utils/tsconfigs/admin",
"include": [
"../plugins/**/admin/src/**/*",
"./"
],
"exclude": [
"node_modules/",
"build/",
"dist/",
"**/*.test.ts"
]
}

View file

@ -0,0 +1,9 @@
'use strict';
/* eslint-disable no-unused-vars */
module.exports = (config, webpack) => {
// Note: we provide webpack above so you should not `require` it
// Perform customizations to webpack config
// Important: return the modified config
return config;
};

View file

View file

@ -0,0 +1,33 @@
{
"kind": "collectionType",
"collectionName": "pages",
"info": {
"singularName": "page",
"pluralName": "pages",
"displayName": "Page",
"description": ""
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {},
"attributes": {
"title": {
"type": "string"
},
"block": {
"type": "json"
},
"slug": {
"type": "string",
"required": false,
"regex": "[A-z0-9\\-]*",
"unique": false
},
"parent": {
"type": "relation",
"relation": "oneToOne",
"target": "api::page.page"
}
}
}

View file

@ -0,0 +1,7 @@
/**
* page controller
*/
import { factories } from '@strapi/strapi'
export default factories.createCoreController('api::page.page');

View file

@ -0,0 +1,7 @@
/**
* page router
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::page.page');

View file

@ -0,0 +1,7 @@
/**
* page service
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::page.page');

View file

18
packages/cms/src/index.ts Normal file
View file

@ -0,0 +1,18 @@
export default {
/**
* An asynchronous register function that runs before
* your application is initialized.
*
* This gives you an opportunity to extend code.
*/
register(/*{ strapi }*/) {},
/**
* An asynchronous bootstrap function that runs before
* your application gets started.
*
* This gives you an opportunity to set up your data model,
* run jobs, or perform some special logic.
*/
bootstrap(/*{ strapi }*/) {},
};

View file

@ -0,0 +1,23 @@
{
"extends": "@strapi/typescript-utils/tsconfigs/server",
"compilerOptions": {
"outDir": "dist",
"rootDir": "."
},
"include": [
"./",
"./**/*.ts",
"./**/*.js",
"src/**/*.json"
],
"exclude": [
"node_modules/",
"build/",
"dist/",
".cache/",
".tmp/",
"src/admin/",
"**/*.test.*",
"src/plugins/**"
]
}

View file

@ -2,6 +2,7 @@
$block: &; $block: &;
display: flex; display: flex;
flex-direction: column;
align-items: stretch; align-items: stretch;
justify-items: stretch; justify-items: stretch;
height: auto; height: auto;

View file

@ -164,6 +164,7 @@ export const SbBlock = defineComponent({
eventRemoveSelf={props.eventRemoveSelf} eventRemoveSelf={props.eventRemoveSelf}
eventActivatePrevious={props.eventActivatePrevious} eventActivatePrevious={props.eventActivatePrevious}
eventActivateNext={props.eventActivateNext} eventActivateNext={props.eventActivateNext}
data-sb-block--content
{...{ {...{
onClick: ($event: MouseEvent) => { onClick: ($event: MouseEvent) => {

View file

@ -1,4 +1,5 @@
.sb-block-ordering { .sb-block-ordering {
font-family: 'Montserrat';
display: flex; display: flex;
position: absolute; position: absolute;
flex-direction: column; flex-direction: column;

View file

@ -1,4 +1,6 @@
.sb-block-picker { .sb-block-picker {
font-family: 'Montserrat';
&__add-button { &__add-button {
padding: 24px 32px; padding: 24px 32px;
} }

View file

@ -1,7 +1,10 @@
.sb-block-placeholder { .sb-block-placeholder {
flex-basis: 100%; font-family: 'Montserrat';
flex-shrink: 2;
position: relative; position: relative;
flex-basis: 1rem;
flex-grow: 0;
flex-shrink: 0;
overflow: visible; overflow: visible;
&__add { &__add {

View file

@ -0,0 +1,3 @@
.sb-block-toolbar {
font-family: 'Montserrat';
}

View file

@ -1,9 +1,14 @@
.sb-button { .sb-button {
font-family: 'Montserrat';
border: 0; border: 0;
padding: 8px 12px; padding: 8px 12px;
background-color: var(--grey-0); background-color: var(--grey-0);
border: 1px solid var(--grey-2); border: 1px solid var(--grey-2);
&_active {
box-shadow: inset 5px 5px 5px 5px black;
}
&:hover { &:hover {
border: 1px solid var(--interact); border: 1px solid var(--interact);
} }

View file

@ -3,6 +3,7 @@
} }
.sb-context-menu { .sb-context-menu {
font-family: 'Montserrat';
display: none; display: none;
flex-direction: column; flex-direction: column;
background: var(--grey-0); background: var(--grey-0);

View file

@ -1,7 +1,13 @@
$sb-style-root: '@schlechtenburg/style';
@import '@schlechtenburg/style/scss/montserrat.scss';
.sb-main { .sb-main {
position: relative; position: relative;
color: var(--fg); color: var(--fg);
background-color: var(--bg); background-color: var(--bg);
padding: 0;
transition: padding 0.2s ease;
overflow: hidden;
--grey-0: white; --grey-0: white;
--grey-1-t: rgba(0, 0, 0, 0.05); --grey-1-t: rgba(0, 0, 0, 0.05);
@ -25,6 +31,7 @@
--z-toolbar: 2000; --z-toolbar: 2000;
--z-context-menu: 3000; --z-context-menu: 3000;
--z-tree-block-select: 4000; --z-tree-block-select: 4000;
--z-main-menu: 5000;
--z-modal: 10000; --z-modal: 10000;
*, *,
@ -32,4 +39,32 @@
*::after { *::after {
box-sizing: border-box; box-sizing: border-box;
} }
&_edit {
padding: 0rem 3rem 3rem 3rem;
}
&--menu {
opacity: 1;
margin-bottom: 3rem;
transform: none;
&-enter-active,
&-leave-active {
transition-property: opacity, margin-bottom, transform;
transition-duration: 0.1s;
transition-timing-function: ease;
}
&-enter-active {
transition-delay: 0s 0s 0.1s;
}
&-enter-from,
&-leave-to {
opacity: 0;
margin-bottom: 0;
transform: translateY(-100%);
}
}
} }

View file

@ -2,8 +2,11 @@ import {
defineComponent, defineComponent,
provide, provide,
shallowReactive, shallowReactive,
shallowRef,
ref, ref,
watch, watch,
computed,
Transition,
PropType, PropType,
Ref, Ref,
} from 'vue'; } from 'vue';
@ -13,6 +16,8 @@ import {
IBlockLibrary, IBlockLibrary,
ITreeNode, ITreeNode,
OnUpdateBlockCb, OnUpdateBlockCb,
IFormattingTool,
IFormattingToolLibrary,
} from '../types'; } from '../types';
import { model } from '../block-helpers'; import { model } from '../block-helpers';
import { SymMode, SbMode } from '../mode'; import { SymMode, SbMode } from '../mode';
@ -24,13 +29,20 @@ import {
} from '../use-block-tree'; } from '../use-block-tree';
import { SymEditorDimensions, useResizeObserver } from '../use-resize-observer'; import { SymEditorDimensions, useResizeObserver } from '../use-resize-observer';
import { SymActiveBlock } from '../use-activation'; import { SymActiveBlock } from '../use-activation';
import { SymRootElement } from '../use-root-element';
import {
SbFormattingToolbar,
SymFormattingToolLibrary,
SymFormattingToolbarClients,
SymFormattingToolbarActiveClient,
} from '../rich-text';
import { SbMainMenu } from './MainMenu'; import { SbMainMenu } from './MainMenu';
import { SbBlockToolbar } from './BlockToolbar';
import { SbBlock } from './Block'; import { SbBlock } from './Block';
export interface ISbMainProps { export interface ISbMainProps {
availableBlocks: IBlockDefinition<any>[]; availableBlocks: IBlockDefinition<any>[];
availableFormattingTools: IFormattingTool[];
block: IBlockData<any>; block: IBlockData<any>;
eventUpdate: OnUpdateBlockCb; eventUpdate: OnUpdateBlockCb;
mode: SbMode; mode: SbMode;
@ -48,6 +60,10 @@ export const SbMain = defineComponent({
type: Array as PropType<IBlockDefinition<any>[]>, type: Array as PropType<IBlockDefinition<any>[]>,
default: () => [], default: () => [],
}, },
availableFormattingTools: {
type: Array as PropType<IFormattingTool[]>,
default: () => [],
},
block: { block: {
type: Object as PropType<IBlockData<any>>, type: Object as PropType<IBlockData<any>>,
required: true, required: true,
@ -71,6 +87,12 @@ export const SbMain = defineComponent({
setup(props: ISbMainProps) { setup(props: ISbMainProps) {
const el: Ref<null|HTMLElement> = ref(null); const el: Ref<null|HTMLElement> = ref(null);
useResizeObserver(el, SymEditorDimensions); useResizeObserver(el, SymEditorDimensions);
provide(SymRootElement, el);
const inlineClients = shallowRef([]);
provide(SymFormattingToolbarClients, inlineClients);
provide(SymFormattingToolbarActiveClient, ref(null));
const mode = ref(props.mode); const mode = ref(props.mode);
provide(SymMode, mode); provide(SymMode, mode);
@ -79,6 +101,11 @@ export const SbMain = defineComponent({
mode.value = newMode; mode.value = newMode;
}); });
const classes = computed(() => ({
'sb-main': true,
[`sb-main_${mode.value}`]: true,
}));
const activeBlock = ref(null); const activeBlock = ref(null);
provide(SymActiveBlock, activeBlock); provide(SymActiveBlock, activeBlock);
@ -93,26 +120,38 @@ export const SbMain = defineComponent({
{}, {},
), ),
}); });
provide(SymBlockLibrary, blockLibrary); provide(SymBlockLibrary, blockLibrary);
const inlineToolLibrary: IFormattingToolLibrary = shallowReactive({
...props.availableFormattingTools.reduce(
(tools: IFormattingToolLibrary, tool: IFormattingTool) => ({ ...tools, [tool.name]: tool }),
{},
),
});
provide(SymFormattingToolLibrary, inlineToolLibrary);
return () => ( return () => (
<div <div
class="sb-main" class={classes.value}
ref={el} ref={el}
> >
{ <Transition
mode.value === SbMode.Edit name="sb-main--menu"
? <> mode="out-in"
<SbMainMenu block={props.block} /> >
<SbBlockToolbar /> {mode.value === SbMode.Edit
</> ? <SbMainMenu
: null block={props.block}
} class="sb-main--menu"
/>
: null}
</Transition>
<SbBlock <SbBlock
class="sb-main--block"
block={props.block} block={props.block}
eventUpdate={props.eventUpdate} eventUpdate={props.eventUpdate}
/> />
<SbFormattingToolbar />
</div> </div>
); );
}, },

View file

@ -1,5 +1,8 @@
.sb-main-menu { .sb-main-menu {
font-family: 'Montserrat';
display: flex; display: flex;
padding-bottom: 4rem;
background-color: var(--grey-0); background-color: var(--grey-0);
position: sticky;
z-index: var(--z-main-menu);
top: 0;
} }

View file

@ -3,6 +3,7 @@ import {
PropType, PropType,
} from 'vue'; } from 'vue';
import { IBlockData } from '../types'; import { IBlockData } from '../types';
import { SbBlockToolbar } from './BlockToolbar';
import { SbTreeBlockSelect } from './TreeBlockSelect'; import { SbTreeBlockSelect } from './TreeBlockSelect';
import './MainMenu.scss'; import './MainMenu.scss';
@ -21,6 +22,7 @@ export const SbMainMenu = defineComponent({
return () => ( return () => (
<div class="sb-main-menu"> <div class="sb-main-menu">
<SbTreeBlockSelect /> <SbTreeBlockSelect />
<SbBlockToolbar />
</div> </div>
); );
}, },

View file

@ -1,3 +1,4 @@
.sb-missing-block { .sb-missing-block {
font-family: 'Montserrat';
flex-basis: 100%; flex-basis: 100%;
} }

View file

@ -1,4 +1,6 @@
.sb-modal { .sb-modal {
font-family: 'Montserrat';
&__overlay { &__overlay {
background-color: var(--grey-3-t); background-color: var(--grey-3-t);
position: fixed; position: fixed;

View file

@ -1,4 +1,5 @@
.sb-select { .sb-select {
font-family: 'Montserrat';
background-color: var(--grey-0); background-color: var(--grey-0);
border: 1px solid var(--grey-2); border: 1px solid var(--grey-2);
position: relative; position: relative;

View file

@ -1,4 +1,5 @@
.sb-toolbar { .sb-toolbar {
font-family: 'Montserrat';
position: absolute; position: absolute;
width: auto; width: auto;
height: auto; height: auto;

View file

@ -1,4 +1,6 @@
.sb-tree-block-select { .sb-tree-block-select {
font-family: 'Montserrat';
&__list { &__list {
list-style: none; list-style: none;
color: var(--fg); color: var(--fg);

View file

@ -1,6 +1,8 @@
export * from './mode'; export * from './mode';
export * from './types'; export * from './types';
export * from './rich-text';
export * from './block-helpers'; export * from './block-helpers';
export * from './use-activation'; export * from './use-activation';
@ -8,10 +10,12 @@ export * from './use-dynamic-blocks';
export * from './use-resize-observer'; export * from './use-resize-observer';
export * from './components/Main'; export * from './components/Main';
export * from './components/Block'; export * from './components/Block';
export * from './components/BlockPicker'; export * from './components/BlockPicker';
export * from './components/BlockOrdering'; export * from './components/BlockOrdering';
export * from './components/BlockPlaceholder'; export * from './components/BlockPlaceholder';
export * from './components/Toolbar'; export * from './components/Toolbar';
export * from './components/Button'; export * from './components/Button';
export * from './components/Select'; export * from './components/Select';

View 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;
}
}

View file

@ -0,0 +1,124 @@
import {
ref,
Ref,
onMounted,
onBeforeUnmount,
defineComponent,
computed,
} from 'vue';
import debounce from 'lodash/debounce';
import { useRootElement } from '../use-root-element';
import { useFormattingToolbar } from './use-formatting-toolbar';
import {
getSelection,
getAnchorElementForSelection,
getRangeFromSelection,
getRect,
} from './selection';
import './FormattingToolbar.scss';
export const SbFormattingToolbar = defineComponent({
name: 'sb-inlinetoolbar',
setup() {
const { rootElement } = useRootElement();
const {
activeClient,
setActiveClient,
clients,
getAllTools,
} = useFormattingToolbar();
const allTools = computed(() => getAllTools());
const selection: Ref<Selection|null>= ref(null);
const selectionRect: Ref<DOMRect|null>= ref(null);
const updateSelectionRect = () => {
selectionRect.value = getRect(selection.value!);
};
const showing: Ref<boolean> = ref(false);
const show = () => {
showing.value = true;
updateSelectionRect();
};
const hide = () => {
showing.value = false;
setActiveClient(null);
};
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,
}));
const onSelectionChanged = debounce(() => {
selection.value = getSelection();
if (!selection.value) {
console.warn('Could not get selection');
return
}
const range = getRangeFromSelection(selection.value);
// If we're not selecting anything, bail
if (!range || range.endOffset === range.startOffset) {
hide();
return;
}
const focusedElement = document.activeElement;
if (!focusedElement) {
hide();
return;
}
// If new selection is not in registered clients, close it
if (!clients.value.find(client => client === focusedElement)) {
hide();
return;
}
setActiveClient(document.activeElement as HTMLElement|null);
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 && showing.value)
? <div
style={style.value}
class={classes.value}
onClick={(event:MouseEvent) => { event.stopPropagation(); }}
>{allTools.value.map((tool) => {
const ToolUI = tool.ui as any;
return <ToolUI
activeClient={activeClient}
selection={selection.value}
></ToolUI>;
})}</div>
: null;
},
});

View file

@ -0,0 +1,69 @@
import {
ref,
Ref,
h,
defineComponent,
onMounted,
onBeforeUnmount,
PropType,
computed,
watchEffect,
} from 'vue';
import { useFormattingToolbar } from './use-formatting-toolbar';
export const SbRichText = defineComponent({
name: 'sb-rich-text',
props: {
inputRef: {
type: (null as unknown) as PropType<Ref<HTMLElement|null>>,
default: ref(null),
},
tag: { type: String, default: 'p' },
value: { type: String, default: '' },
onValueChange: {
type: (null as unknown) as PropType<(value: string) => void>,
default: (_:string) => {},
},
},
setup(props) {
const {
registerClient,
unregisterClient,
} = useFormattingToolbar();
const onKeydown = (event: KeyboardEvent) => {
if (['Delete', 'Backspace'].indexOf(event.key) < 0) {
return;
}
if (props.inputRef.value?.textContent === '') {
props.inputRef.value.innerHTML = '';
}
};
onMounted(() => {
registerClient(props.inputRef.value!);
});
onBeforeUnmount(() => {
unregisterClient(props.inputRef.value!);
});
return () => h(
props.tag,
{
class: 'sb-contenteditable',
contenteditable: 'true',
ref: props.inputRef,
onKeydown,
onInput: () => {
props.onValueChange(props.inputRef.value?.innerHTML || '');
},
},
);
},
});

View 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;
}

View file

@ -0,0 +1,5 @@
export * from './FormattingToolbar';
export * from './RichText';
export * from './use-formatting-toolbar';
export * from './selection';
export * from './dom';

View 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;
};

View file

@ -0,0 +1,58 @@
import {
ref,
Ref,
inject,
reactive,
} from 'vue';
import { IFormattingToolLibrary } from '../types';
export const SymFormattingToolbarActiveClient = Symbol('Schlechtenburg inline active client');
export const SymFormattingToolbarClients = Symbol('Schlechtenburg inline toolbar client elements');
export const SymFormattingToolLibrary = Symbol('Schlechtenburg inline tool library');
export const useFormattingToolbar = () => {
const clients: Ref<HTMLElement[]> = inject(SymFormattingToolbarClients, ref([]));
const activeClient: Ref<HTMLElement|null> = inject(SymFormattingToolbarActiveClient, ref(null));
const setActiveClient = (client: HTMLElement|null) => {
activeClient.value = client;
};
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));
};
const tools: IFormattingToolLibrary = inject(SymFormattingToolLibrary, reactive({}));
const getTool = (name: string) => tools[name];
const getAllTools = () => Object.keys(tools).map(name => tools[name]);
return {
clients,
activeClient,
setActiveClient,
registerClient,
unregisterClient,
tools,
getTool,
getAllTools,
};
};

View file

@ -0,0 +1,261 @@
import defaultTo from 'lodash/defaultTo';
import { IFormattingTool } from '../types';
export interface IRichTextFormat {
type: string;
}
export interface IRichTextValue {
text: string;
formats: IRichTextFormat[][];
start: number|null;
end: number|null;
}
export const findToolForElement = (
tools: IFormattingTool[],
element: Element,
): IFormattingTool|null => tools.find(tool => {
if (tool.tagName && element.tagName !== tool.tagName) {
return false;
}
if (tool.className && !element.classList.contains(tool.className)) {
return false;
}
return true;
}) || null;
export const createFromString = (
value: string = '',
formats: IRichTextFormat[] = [],
): IRichTextValue => ({
text: value,
formats: (new Array(value.length)).fill([...formats]),
start: null,
end: null,
});
const createFromDOMNodeRecursively = (
tools: IFormattingTool[],
node: ChildNode,
formats: IRichTextFormat[] = [],
): IRichTextValue => {
if (node.nodeType === node.TEXT_NODE) {
return createFromString(node.textContent || '', formats);
}
if (node.nodeType === node.ELEMENT_NODE) {
return createFromDOMElement(tools, node as Element, formats);
}
return createFromString('', []);
};
export const createFromDOMElement = (
tools: IFormattingTool[],
element: Element,
formats: IRichTextFormat[] = [],
): IRichTextValue => {
const tool = findToolForElement(tools, element);
const subFormats = tool
? [
...formats.filter(f => f.type !== tool.name),
{ type: tool.name },
]
: [ ...formats ];
const nodes = Array.from(element.childNodes);
return concat(...nodes.map(node => createFromDOMNodeRecursively(tools, node, subFormats)));
};
export const createFromDOMString = (
tools: IFormattingTool[],
value: string,
formats: IRichTextFormat[] = [],
): IRichTextValue => {
const div = document.createElement('div');
div.innerHTML = value;
return createFromDOMElement(tools, div, formats);
};
export const create = (
tools: IFormattingTool[],
value: Element|string = '',
): IRichTextValue => {
if (typeof value === 'string') {
return createFromDOMString(tools, value);
}
return createFromDOMElement(tools, value);
}
export const toHTMLString = (
tools: IFormattingTool[],
value: IRichTextValue,
) => {
const tools = [...tools].sort((a, b) => {
const tensionA = defaultTo(a.surfaceTension, Infinity);
const tensionB = defaultTo(b.surfaceTension, Infinity);
return tensionA - tensionB;
});
const elementTree = [];
const string = '';
for (let i = 0; i < value.text.length; i++) {
const c = value.text[i];
const formats = value.formats[i];
const activeFormats = value.formats[i - 1] || [];
const removedFormats = activeFormats
.filter(a => !formats
.find(f => f.type === a.type
&& JSON.stringify(f) === JSON.stringify(a)));
const addedFormats = formats
.filter(f => !activeFormats
.find(a => a.type === f.type
&& JSON.stringify(a) === JSON.stringify(f)));
console.log(c);
for (let removedFormat of removedFormats) {
const tool = tools.find(tool => tool.name === removedFormat.type);
if (!tool) {
continue;
}
tool.
}
}
return string;
}
export const getActiveFormats = (richTextValue: IRichTextValue): IRichTextFormat[] =>
richTextValue.start !== null
? richTextValue.formats[richTextValue.start]
: [];
export const applyFormat = (
value: IRichTextValue,
format: IRichTextFormat,
startIndex?: number,
endIndex?: number,
): IRichTextValue => {
const start = defaultTo(defaultTo(startIndex, value.start), 0);
const end = defaultTo(defaultTo(endIndex, value.end), value.text.length);
return {
...value,
formats: [
...value.formats.slice(0, start),
...value.formats.slice(start, end).map(letterFormatList => [
...letterFormatList.filter(letterFormat => letterFormat.type === format.type),
format,
]),
...value.formats.slice(end),
],
}
};
export const removeFormat = (
value: IRichTextValue,
format: IRichTextFormat,
startIndex?: number,
endIndex?: number,
): IRichTextValue => {
const start = defaultTo(defaultTo(startIndex, value.start), 0);
const end = defaultTo(defaultTo(endIndex, value.end), value.text.length);
return {
...value,
formats: [
...value.formats.slice(0, start),
...value.formats.slice(start, end)
.map(letterFormatList => letterFormatList.filter(letterFormat => letterFormat.type === format.type)),
...value.formats.slice(end),
],
};
}
export const toggleFormat = (
value: IRichTextValue,
format: IRichTextFormat,
): IRichTextValue => {
const activeFormats = getActiveFormats(value);
if (activeFormats.find(f => f.type === format.type)) {
return removeFormat(value, format);
}
return applyFormat(value, format);
}
export const concat = (...richTextValues:IRichTextValue[]): IRichTextValue => richTextValues
.reduce((newValue, value) => ({
text: newValue.text + value.text,
formats: [
...newValue.formats,
...value.formats,
],
start: newValue.start !== null ? newValue.start : value.start,
end: value.end !== null ? value.end : newValue.end,
}), { text: '', formats: [], start: null, end: null });
export const join = (
richTextValues:IRichTextValue[],
separator?: string|IRichTextValue,
): IRichTextValue => richTextValues
.reduce((total, value) => concat(
total,
...(separator
? [typeof separator === 'string' ? createFromString(separator) : separator]
: []),
value,
), createFromString());
export const slice = (
value: IRichTextValue,
startIndex?: number,
endIndex?: number,
): IRichTextValue => {
const start = defaultTo(defaultTo(startIndex, value.start), 0);
const end = defaultTo(defaultTo(endIndex, value.end), value.text.length);
return {
text: value.text.slice(start, end),
formats: value.formats.slice(start, end),
start: value.start !== null ? (value.start >= start ? value.start - start : null) : null,
end: value.end !== null ? (value.end <= end ? end - value.end : null) : null,
};
};
export const insert = (
value: IRichTextValue,
valueToInsert: IRichTextValue|string,
startIndex?: number,
endIndex?: number,
) => {
const start = defaultTo(defaultTo(startIndex, value.start), value.text.length);
const end = defaultTo(defaultTo(endIndex, value.end), value.text.length);
return concat(
slice(value, 0, start),
typeof valueToInsert === 'string' ? createFromString(valueToInsert) : valueToInsert,
slice(value, end, value.text.length),
);
}
export const split = (
value: IRichTextValue,
valueToInsert: IRichTextValue|string,
startIndex?: number,
endIndex?: number,
) => {
const start = defaultTo(defaultTo(startIndex, value.start), value.text.length);
const end = defaultTo(defaultTo(endIndex, value.end), value.text.length);
return concat(
slice(value, 0, start),
typeof valueToInsert === 'string' ? createFromString(valueToInsert) : valueToInsert,
slice(value, end, value.text.length),
);
}

View file

@ -170,3 +170,21 @@ export interface IBlockDefinition<T> {
export interface IBlockLibrary { export interface IBlockLibrary {
[name: string]: IBlockDefinition<any>; [name: string]: IBlockDefinition<any>;
} }
export interface IFormattingTool {
name: string;
icon: string;
tagName?: string;
className?: string;
edit: Component;
surfaceTension?: number;
}
/**
* Schlechtenburg maintains a library of formatting tools that are available
*
* @internal
*/
export interface IFormattingToolLibrary {
[name: string]: IFormattingTool;
}

View file

@ -7,7 +7,7 @@ import {
} from 'vue'; } from 'vue';
export const SymActiveBlock = Symbol('Schlechtenburg active block'); 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 activeBlockId: Ref<string|null> = inject(SymActiveBlock, ref(null));
const isActive = computed(() => activeBlockId.value === currentBlockId); const isActive = computed(() => activeBlockId.value === currentBlockId);
@ -46,4 +46,4 @@ export function useActivation(currentBlockId: string|null = null) {
deactivate, deactivate,
requestActivation, requestActivation,
}; };
} };

View file

@ -10,14 +10,12 @@ import {
ITreeNode, ITreeNode,
IBlockData, IBlockData,
} from './types'; } from './types';
import { useDynamicBlocks } from './use-dynamic-blocks';
import { SbMode } from './mode';
export const SymBlockTree= Symbol('Schlechtenburg block tree'); export const SymBlockTree= Symbol('Schlechtenburg block tree');
export const SymBlockTreeRegister = Symbol('Schlechtenburg block tree register'); export const SymBlockTreeRegister = Symbol('Schlechtenburg block tree register');
export const SymBlockTreeUnregister = Symbol('Schlechtenburg block tree unregister'); export const SymBlockTreeUnregister = Symbol('Schlechtenburg block tree unregister');
export function useBlockTree() { export const useBlockTree = () => {
const blockTree: Ref<ITreeNode|null> = inject(SymBlockTree, ref(null)); const blockTree: Ref<ITreeNode|null> = inject(SymBlockTree, ref(null));
const registerWithParent = inject(SymBlockTreeRegister, (_b: ITreeNode, _i: number) => {}); const registerWithParent = inject(SymBlockTreeRegister, (_b: ITreeNode, _i: number) => {});
const unregisterWithParent = inject(SymBlockTreeUnregister, (_b: ITreeNode) => {}); const unregisterWithParent = inject(SymBlockTreeUnregister, (_b: ITreeNode) => {});
@ -52,18 +50,11 @@ export function useBlockTree() {
self.children = self.children.filter((child: ITreeNode) => child.id !== id); self.children = self.children.filter((child: ITreeNode) => child.id !== id);
}); });
const { mode } = useDynamicBlocks();
const register = (block: IBlockData<any>, index: number = 0) => { const register = (block: IBlockData<any>, index: number = 0) => {
if (!block.id) { if (!block.id) {
throw new Error(`Cannot register a block without an id: ${JSON.stringify(block)}`); throw new Error(`Cannot register a block without an id: ${JSON.stringify(block)}`);
} }
if (mode.value !== SbMode.Edit) {
console.warn('Ignoring block tree registration requests outside of edit mode.');
return;
}
self.id = block.id; self.id = block.id;
self.name = block.name; self.name = block.name;
@ -82,4 +73,4 @@ export function useBlockTree() {
blockTree, blockTree,
register, register,
}; };
} };

View file

@ -16,7 +16,7 @@ interface BlockRect {
export const SymBlockDimensions = Symbol('Schlechtenburg block dimensions'); export const SymBlockDimensions = Symbol('Schlechtenburg block dimensions');
export const SymEditorDimensions = Symbol('Schlechtenburg editor 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); const dimensions: Ref<null|BlockRect> = ref(null);
provide(symbol, dimensions); provide(symbol, dimensions);
const triggerSizeCalculation = () => { const triggerSizeCalculation = () => {
@ -47,7 +47,7 @@ export function useResizeObserver(el: Ref<null|HTMLElement>, symbol: symbol) {
}) })
return { triggerSizeCalculation, dimensions }; return { triggerSizeCalculation, dimensions };
} };
export function useBlockSizing() { export function useBlockSizing() {
const editorDimensions: Ref<BlockRect|null> = inject(SymEditorDimensions, ref(null)); const editorDimensions: Ref<BlockRect|null> = inject(SymEditorDimensions, ref(null));

View 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 };
};

File diff suppressed because it is too large Load diff

View file

@ -7,7 +7,6 @@
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"main": "lib/index.ts", "main": "lib/index.ts",
"scripts": { "scripts": {
"dev": "npm run json-to-md:watch",
"typecheck": "vuedx-typecheck --no-pretty ./lib", "typecheck": "vuedx-typecheck --no-pretty ./lib",
"test": "echo \"Error: run tests from root\" && exit 1" "test": "echo \"Error: run tests from root\" && exit 1"
}, },
@ -27,6 +26,8 @@
"url": "git@git.b12f.io:b12f/schlechtenburg.git" "url": "git@git.b12f.io:b12f/schlechtenburg.git"
}, },
"dependencies": { "dependencies": {
"@schlechtenburg/style": "^0.0.0",
"@wordpress/rich-text": "^6.0.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"uuid": "^8.3.2" "uuid": "^8.3.2"
}, },
@ -36,6 +37,7 @@
"devDependencies": { "devDependencies": {
"@types/lodash-es": "^4.17.4", "@types/lodash-es": "^4.17.4",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"@types/wordpress__rich-text": "^3.4.6",
"@vuedx/typecheck": "^0.6.3", "@vuedx/typecheck": "^0.6.3",
"@vuedx/typescript-plugin-vue": "^0.6.3", "@vuedx/typescript-plugin-vue": "^0.6.3",
"vue": "^3.2.31" "vue": "^3.2.31"

8
packages/example-site/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
node_modules
*.log*
.nuxt
.nitro
.cache
.output
.env
dist

View file

@ -0,0 +1,42 @@
# Nuxt 3 Minimal Starter
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install the dependencies:
```bash
# yarn
yarn install
# npm
npm install
# pnpm
pnpm install --shamefully-hoist
```
## Development Server
Start the development server on http://localhost:3000
```bash
npm run dev
```
## Production
Build the application for production:
```bash
npm run build
```
Locally preview production build:
```bash
npm run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

View file

@ -0,0 +1,79 @@
body {
margin: 0;
}
.ex-app {
display: flex;
width: 100vw;
min-height: 100vh;
justify-content: space-between;
align-items: stretch;
--ex-nav-mobile-width: 60px;
--ex-nav-desktop-width: 320px;
--ex-nav-width: var(--ex-nav-mobile-width);
--interact: #3f9cff;
--interact-lite: #3f9cff;
--grey-0: white;
--grey-1-t: rgba(0, 0, 0, 0.05);
--grey-1: rgb(242, 242, 242);
--grey-2-t: rgba(0, 0, 0, 0.1);
--grey-2: rgb(230, 230, 230);
--grey-3-t: rgba(0, 0, 0, 0.2);
--grey-3: rgb(205, 205, 205);
--grey-4-t: rgba(0, 0, 0, 0.4);
--grey-4: rgb(155, 155, 155);
--grey-5-t: rgba(0, 0, 0, 0.7);
--grey-5: rgb(75, 75, 75);
--black: rgba(0, 0, 0, 0.9);
--bg: var(--grey-1);
--fg: var(--black);
--interact: #3f9cff;
--interact-lite: #3f9cff;
--z-toolbar: 2000;
--z-context-menu: 3000;
--z-tree-block-select: 4000;
--z-modal: 10000;
@media screen and (min-width: 1000px) {
--ex-nav-width: var(--ex-nav-desktop-width);
}
* {
box-sizing: border-box;
}
&--edit-nav {
z-index: 100;
position: fixed;
top: 0;
left: 0;
bottom: 0;
@media screen and (min-width: 1000px) {
position: unset;
width: unset;
flex-basis: var(--ex-nav-width);
flex-grow: 0;
flex-shrink: 0;
}
}
&--page {
flex-basis: 100%;
&:not(:first-child) {
margin-left: var(--ex-nav-width);
@media screen and (min-width: 1000px) {
margin-left: 0;
}
}
}
}

View file

@ -0,0 +1,20 @@
import { defineComponent } from 'vue';
import { NuxtPage } from '#components';
import './app.scss';
const AdminNav = defineAsyncComponent(() => import('~~/components/_/Nav'));
const Toaster = defineAsyncComponent(() => import('~~/components/_/Toaster'));
export default defineComponent({
setup() {
const { me } = useMe();
return () => (
<div class="ex-app">
{me.value ? <AdminNav class="ex-app--edit-nav" /> : null}
<NuxtPage class="ex-app--page" />
{me.value ? <Toaster /> : null}
</div>
);
},
});

View file

@ -0,0 +1,12 @@
.ex-page {
display: flex;
flex-direction: column;
&--editor {
flex-grow: 1;
}
&_missing {
margin: 4rem;
}
}

View file

@ -0,0 +1,88 @@
import { defineComponent } from 'vue';
import {
SbMain,
SbButton,
} from '@schlechtenburg/core';
import PageToolbar from '~~/components/PageToolbar';
import SbLayout from '@schlechtenburg/layout';
import SbHeading from '@schlechtenburg/heading';
import SbParagraph from '@schlechtenburg/paragraph';
import SbImage from '@schlechtenburg/image';
import './Page.scss';
export default defineComponent({
async setup() {
const { me } = useMe();
const loggedIn = computed(() => !!me.value?.id);
const { setCurrentPageId, currentPage } = useCurrentPage();
const block = computed(() => currentPage.value?.attributes?.block);
if (!block) {
console.error('No block!');
console.error('page', currentPage.value);
}
const {
mode,
draft,
updateDraft,
} = useEditor();
const { edit } = useEditor();
watchEffect(() => {
updateDraft(block.value!);
});
const { insertPage } = usePages();
const createPageHere = () => {
insertPage({
id: 'draft',
attributes: {
title: 'New page',
block: getNewPageBlock(),
slug: '',
parent: {
data: {
id: currentPage.value?.id,
},
},
},
});
setCurrentPageId('draft');
edit(currentPage.value?.attributes?.block!);
};
return () => (
<div class="ex-page">
{loggedIn.value ? <PageToolbar></PageToolbar> : null}
{draft.value
? <SbMain
class="ex-page--editor"
mode={mode.value}
eventUpdate={(updatedBlock) => updateDraft(updatedBlock)}
block={draft.value}
availableBlocks={[
SbLayout,
SbHeading,
SbParagraph,
SbImage,
]}
/>
: <div class="ex-page ex-page_missing">
<h1>Ooops!</h1>
<p>This page does not exist yet. However, you can create it right now!</p>
<SbButton
type="button"
onClick={() => createPageHere()}
>Create a page here</SbButton>
</div>}
</div>
);
},
});

View file

@ -0,0 +1,3 @@
.ex-page-breadcrumb {
display: flex;
}

View file

@ -0,0 +1,27 @@
import { ComputedRef, defineComponent } from 'vue';
import { NuxtLink } from '#components';
import { IPage } from '~~/composables/pages';
import './PageBreadcrumb.scss';
export default defineComponent({
async setup() {
const { currentPage } = useCurrentPage();
const { pages } = usePages();
const parents:ComputedRef<IPage[]> = computed(() => currentPage.value ? getPageParents(currentPage.value, pages.value, [currentPage.value]) : []);
return () => {
return (<div class="ex-page-breadcrumb">
{...parents.value.map((parent) => (
<div class="ex-page-breadcrumb--crumb">
/
<NuxtLink to={getPagePath(parent, pages.value)}>
{parent?.attributes?.slug}
</NuxtLink>
</div>
))}
</div>);
};
},
});

View file

@ -0,0 +1,7 @@
.ex-page-toolbar {
position: sticky;
top: 0;
border-bottom: 1px solid var(--grey-2);
display: flex;
justify-content: center;
}

View file

@ -0,0 +1,72 @@
import { defineComponent } from 'vue';
import { SbButton, SbMode } from '@schlechtenburg/core';
import PageBreadcrumb from '~~/components/PageBreadcrumb';
import './PageToolbar.scss';
export default defineComponent({
async setup() {
const {
currentPage,
currentPageId,
setCurrentPageId,
} = useCurrentPage();
const { pages, insertPage } = usePages();
const {
mode,
edit,
cancel,
save,
} = useEditor();
const addChildPage = () => {
insertPage({
id: 'draft',
attributes: {
title: 'New page',
block: getNewPageBlock(),
slug: 'new-page',
parent: {
data: {
id: currentPage.value?.id,
},
},
},
});
setCurrentPageId('draft');
edit(currentPage.value?.attributes?.block!);
};
return () => (
<div class="ex-page-toolbar">
<PageBreadcrumb />
{currentPageId.value !== 'draft' && !!currentPageId.value
? <SbButton
type="button"
onClick={() => addChildPage()}
>Add child page</SbButton>
: null}
{!!currentPageId.value
? (mode.value === SbMode.View
? <SbButton
type="button"
onClick={() => edit(currentPage.value?.attributes?.block!)}
>Edit</SbButton>
: <>
<SbButton
type="button"
onClick={() => cancel()}
>Cancel</SbButton>
<SbButton
type="button"
onClick={() => save()}
>Save</SbButton>
</>)
: null}
</div>
);
},
});

View file

@ -0,0 +1,84 @@
.ex-edit-nav {
background-color: white;
width: var(--ex-nav-width);
display: flex;
flex-direction: column;
align-items: stretch;
.icon {
height: 1.5rem;
width: 1.5rem;
}
&_expanded {
width: 300px;
max-width: 80vw;
}
&--toggle {
@media screen and (min-width: 1000px) {
display: none;
}
}
&--menu {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
list-style: none;
flex-grow: 1;
border-right: 1px solid var(--grey-2);
&-spacer {
flex-grow: 1;
}
}
&--menu-item {
&-action {
position: relative;
width: 100%;
height: var(--ex-nav-mobile-width);
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
color: var(--fg);
background-color: var(--bg);
font-weight: bold;
border: 0;
cursor: pointer;
padding: 12px;
text-decoration: none;
&:hover {
color: var(--bg);
background-color: var(--interact);
}
}
&-title {
opacity: 0.001;
height: auto;
width: calc(100% - 60px);
overflow: hidden;
margin: 0px;
@media screen and (min-width: 1000px) {
opacity: 1;
}
}
}
&_expanded &--menu-item {
&-action {
justify-content: flex-start;
}
&-title {
opacity: 1;
margin-left: 16px;
}
}
}

View 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>
);
},
});

View file

@ -0,0 +1,28 @@
.ex-toaster {
position: fixed;
bottom: 0;
right: 0;
display: flex;
flex-direction: column;
&--toast {
margin: 1rem 2rem;
border-radius: 4px;
&_info {
background-color: var(--info);
}
&_success {
background-color: var(--success);
}
&_warning {
background-color: var(--warning);
}
&_error {
background-color: var(--error);
}
}
}

View file

@ -0,0 +1,15 @@
import { defineComponent } from 'vue';
import './Toaster.scss';
export default defineComponent({
setup() {
const { toasts } = useToaster();
return () => (
<nav class="ex-toaster">
{toasts.value.map(toast => <div class={`ex-toaster--toast ex-toaster--toast_${toast.type}`}>{toast.content}</div>)}
</nav>
);
},
});

View file

@ -0,0 +1,107 @@
import { IBlockData, SbMode } from "@schlechtenburg/core";
import { IPage } from "./pages";
export const useEditor = () => {
const {
currentPage,
currentPageId,
setCurrentPageId,
} = useCurrentPage();
const {
removePage,
updatePage,
insertPage,
pages,
} = usePages();
const mode = useState<SbMode>('mode', () => SbMode.View);
const draft = useState<IBlockData<any>|null>('draft', () => null);
const setMode = (newMode: SbMode) => {
mode.value = newMode;
};
const updateDraft = (newDraft: IBlockData<any>) => {
draft.value = newDraft;
}
const removeDraft = () => {
draft.value = null;
}
const edit = (block: IBlockData<any>) => {
draft.value = block;
mode.value = SbMode.Edit;
updateDraft(currentPage.value?.attributes?.block!);
};
const save = async () => {
if (currentPageId.value === 'draft') {
const { data, error } = await useAsyncGql(
'createPage',
{ data: {
title: currentPage.value?.attributes?.title,
slug: currentPage.value?.attributes?.slug,
block: currentPage.value?.attributes?.block,
parent: currentPage.value?.attributes?.parent?.data?.id,
publishedAt: (new Date()).toISOString(),
}}
);
if (error.value) {
console.error('Error creating page!');
console.error('error:', error.value);
console.error('data:', data.value);
return;
}
setMode(SbMode.View);
insertPage(data.value?.createPage?.data! as IPage);
removeDraft();
navigateTo(getPagePath(data.value?.createPage?.data! as IPage, pages.value));
return;
} else {
const { data, error } = await useAsyncGql(
'updatePage',
{
id: currentPage.value?.id || '',
data: {
block: draft.value!
},
},
);
if (error.value) {
console.error('Error updating page!');
console.error('error:', error.value);
console.error('data:', data.value);
return;
}
setMode(SbMode.View);
updatePage(data.value?.updatePage?.data?.attributes?.block);
updateDraft(data.value?.updatePage?.data?.attributes?.block);
}
};
const cancel = () => {
setMode(SbMode.View);
if (currentPageId.value === 'draft') {
setCurrentPageId(currentPage.value?.attributes?.parent?.data?.id || null);
removePage('draft');
}
};
return {
mode,
setMode,
edit,
cancel,
save,
draft,
updateDraft,
};
};

View file

@ -0,0 +1,31 @@
export interface IRole {
id?: string|null;
attributes?: {
description: string;
name: string;
};
}
export interface IUser {
id?: string|null;
attributes?: {
blocked?: boolean;
confirmed?: boolean;
email?: string;
role?: IRole;
username?: string;
};
}
export const useMe = () => {
const me = useState<IUser|null>('me', () => null);
const setMe = (user: IUser|null) => {
me.value = user;
};
return {
me,
setMe,
};
};

View file

@ -0,0 +1,104 @@
import { IBlockData } from "@schlechtenburg/core";
export interface IPage {
id?: string|null;
attributes?: {
title?: string;
block?: IBlockData<any>|null;
slug?: string;
parent?: {
data?: {
id?: string|null;
};
};
};
}
export const usePages = () => {
const pages = useState<IPage[]|[]>('pages', () => []);
const getPage = (id:string) => pages.value.find(p => p.id === id);
const setPages = (newPages: IPage[] = []) => {
pages.value = newPages;
};
const updatePage = (page: Partial<IPage>) => {
const existing = pages.value.find(p => p.id === page.id);
if (!existing) {
console.warn('Could not update page because it was not found in the store', page);
return;
}
setPages([
...pages.value.filter(p => p.id !== page.id),
{
id: existing.id,
attributes: {
...existing.attributes,
...page.attributes,
},
},
]);
};
const removePage = (id: string) => {
setPages(pages.value.filter(p => p.id !== id));
};
const insertPage = (page: IPage) => {
setPages([
...pages.value,
page,
]);
};
const fetchPages = async () => {
const { data, error } = await useAsyncGql('pages');
setPages(data.value?.pages?.data as IPage[]);
}
return {
pages,
setPages,
getPage,
fetchPages,
insertPage,
updatePage,
removePage,
};
};
export const useCurrentPage = () => {
const { pages, insertPage } = usePages();
const currentPageId = useState<string|null>('currentPageId', () => null);
const setCurrentPage = (newPage: IPage|null) => {
if (!newPage || !newPage.id) {
currentPageId.value = null;
return;
}
if (!pages.value.find(p => p.id === newPage.id)) {
insertPage(newPage);
}
currentPageId.value = newPage.id;
};
const setCurrentPageId = (newPageId: string|null) => {
currentPageId.value = newPageId;
};
const currentPage = computed(() => pages.value.find(p => p.id === currentPageId.value));
return {
currentPage,
setCurrentPage,
currentPageId,
setCurrentPageId,
};
};

View file

@ -0,0 +1,48 @@
export enum ToastType {
SUCCESS = 'success',
INFO = 'info',
WARNING = 'warning',
ERROR = 'error',
}
export interface IToaster {
type: ToastType,
content: string;
}
interface IToastedToaster extends IToaster {
id: number;
}
export const DEFAULT_TOAST_TIME = 5000;
export const useToaster = () => {
const toasts = useState<IToastedToaster[]>('toasts', () => []);
const removeToast = (id: number) => {
toasts.value = toasts.value.filter(toast => toast.id !== id);
};
const showToast = (toast: IToaster, time = DEFAULT_TOAST_TIME) => {
const id = +(new Date());
toasts.value = [
...toasts.value,
{
...toast,
id,
},
];
setTimeout(() => {
removeToast(id);
}, time);
return id;
};
return {
toasts,
showToast,
removeToast,
};
};

View file

@ -0,0 +1,9 @@
import { IUser } from "~~/composables/states";
export default defineNuxtRouteMiddleware(async () => {
const { me, setMe } = useMe();
const { data } = await useAsyncGql('me');
setMe((data.value?.me as IUser) || null);
});

View file

@ -0,0 +1,39 @@
import { IPage } from "~~/composables/pages";
export default defineNuxtRouteMiddleware(async (to) => {
const { setCurrentPage } = useCurrentPage();
const { fetchPages } = usePages();
const pathParts = to.path.split('/').filter(p => p !== '');
pathParts.unshift('');
const filters = pathParts.reduce((total, part) => {
return {
id: { ne: null },
slug: { eq: part },
parent: total,
};
}, {});
const [{ data, error }] = await Promise.all([
useAsyncGql({
operation: 'pages',
variables: { filters },
}),
fetchPages(),
]);
if (error.value) {
console.error('Error getting pages!');
console.error(error.value);
return;
}
const newPage = (data.value?.pages?.data[0] as IPage) || null;
if (!newPage) {
setResponseStatus(404)
return;
}
setCurrentPage(newPage);
});

View file

@ -0,0 +1,14 @@
import { defineNuxtConfig } from 'nuxt/config';
export default defineNuxtConfig({
modules: [
'nuxt-graphql-client',
'nuxt-icon',
],
runtimeConfig: {
public: {
GQL_HOST: 'http://localhost:1337/graphql', // overwritten by process.env.GQL_HOST
},
},
});

21042
packages/example-site/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
{
"name": "@schlechtenburg/example-site",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"devDependencies": {
"nuxt": "3.0.0"
},
"dependencies": {
"@graphql-codegen/cli": "^2.16.1",
"@schlechtenburg/core": "^0.0.0",
"@schlechtenburg/heading": "^0.0.0",
"@schlechtenburg/image": "^0.0.0",
"@schlechtenburg/layout": "^0.0.0",
"@schlechtenburg/paragraph": "^0.0.0",
"@schlechtenburg/style": "^0.0.0",
"event-target-polyfill": "^0.0.3",
"nuxt-graphql-client": "^0.2.23",
"nuxt-icon": "^0.1.8",
"sass": "^1.57.1"
}
}

View file

@ -0,0 +1,12 @@
import { defineComponent } from 'vue';
import Page from '~~/components/Page';
export default defineComponent({
async setup() {
definePageMeta({
middleware: ['authenticate', 'page'],
});
return () => <Page />;
},
});

View file

@ -0,0 +1,68 @@
import { defineComponent } from 'vue';
export default defineComponent({
async setup() {
definePageMeta({
middleware: ['authenticate'],
});
const credentials = useState(() => ({
identifier: '',
password: '',
}));
const onSubmit = async (event:Event) => {
event.preventDefault();
const { data, error } = await useAsyncGql('login', credentials.value);
const { setMe } = useMe();
if (error.value) {
console.error('Failed to log in!');
console.error('error:', error.value);
console.error('data:', data.value);
setMe(null);
return;
}
useGqlToken({
token: data.value?.login?.jwt || null,
config: { type: 'Bearer' },
});
setMe(data.value?.login?.user || null);
navigateTo('/');
};
return () => (<div class="sbcms-admin">
<form
onSubmit={onSubmit}
>
<h1>Login</h1>
<label>
Username
<input
type="text"
value={credentials.value.identifier}
onChange={(event: Event) => {
credentials.value.identifier = (event.currentTarget as HTMLInputElement).value;
}}
/>
</label>
<label>
Password
<input
type="password"
value={credentials.value.password}
onChange={(event: Event) => {
credentials.value.password = (event.currentTarget as HTMLInputElement).value;
}}
/>
</label>
<button
type="submit"
>Log in</button>
</form>
</div>);
},
});

View file

@ -0,0 +1,12 @@
import { defineComponent } from 'vue';
import Page from '~~/components/Page';
export default defineComponent({
async setup() {
definePageMeta({
middleware: ['authenticate', 'page'],
});
return () => <Page />;
},
});

View file

@ -0,0 +1,17 @@
mutation createPage($data: PageInput!) {
createPage(data: $data) {
data {
id
attributes {
title
slug
block
parent {
data {
id
}
}
}
}
}
}

View file

@ -0,0 +1,17 @@
mutation login($identifier: String!, $password: String!) {
login(input: { identifier: $identifier, password: $password }) {
jwt
user {
id
username
blocked
confirmed
email
role {
id
description
name
}
}
}
}

View file

@ -0,0 +1,15 @@
query me {
me {
id
username
blocked
confirmed
email
role {
id
description
name
}
}
}

View file

@ -0,0 +1,17 @@
query page($id: ID) {
page(id: $id) {
data {
id
attributes {
title
slug
block
parent {
data {
id
}
}
}
}
}
}

View file

@ -0,0 +1,17 @@
query pages($filters: PageFiltersInput) {
pages(filters: $filters) {
data {
id
attributes {
title
slug
block
parent {
data {
id
}
}
}
}
}
}

View file

@ -0,0 +1,17 @@
mutation updatePage($id: ID!, $data: PageInput!) {
updatePage(id: $id, data: $data) {
data {
id
attributes {
title
slug
block
parent {
data {
id
}
}
}
}
}
}

View file

@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

View file

@ -0,0 +1,43 @@
import { generateBlockId, IBlockData } from "@schlechtenburg/core";
import { getDefaultData as getDefaultLayoutData, ILayoutData, name as layoutName } from "@schlechtenburg/layout";
import { getDefaultData as getDefaultHeadingData, name as headingName } from "@schlechtenburg/heading";
import { IPage } from "~~/composables/pages";
export const getNewPageBlock: () => IBlockData<ILayoutData> = () => ({
id: generateBlockId(),
name: layoutName,
data: getDefaultLayoutData({
children: [
{
id: generateBlockId(),
name: headingName,
data: getDefaultHeadingData({ value: 'New page' }),
}
],
}),
});
export const getPageParents = (page: IPage, pages: IPage[], parents: IPage[]):IPage[] => {
const parent = pages.find(p => p.id === page.attributes?.parent?.data?.id);
if (!parent) {
return parents;
}
return getPageParents(
parent,
pages,
[
parent,
...parents,
],
);
};
export const getPagePath = (page: IPage, pages: IPage[]) => {
const ancestors = [
...getPageParents(page, pages, []),
page,
];
return ancestors.reduce((path, page) => page.attributes?.slug ? `${path}/${page.attributes?.slug}` : path, '');
}

View file

@ -4,8 +4,9 @@ export interface IHeadingData {
level: number; level: number;
} }
export const getDefaultData: () => IHeadingData = () => ({ export const getDefaultData: (data?: Partial<IHeadingData>) => IHeadingData = (data = {}) => ({
value: '', value: '',
align: 'left', align: 'left',
level: 1, level: 1,
...data,
}); });

File diff suppressed because it is too large Load diff

View file

@ -22,7 +22,6 @@
"url": "git@git.b12f.io:b12f/schlechtenburg.git" "url": "git@git.b12f.io:b12f/schlechtenburg.git"
}, },
"scripts": { "scripts": {
"dev": "npm run json-to-md:watch",
"typecheck": "vuedx-typecheck --no-pretty ./lib", "typecheck": "vuedx-typecheck --no-pretty ./lib",
"test": "echo \"Error: run tests from root\" && exit 1" "test": "echo \"Error: run tests from root\" && exit 1"
}, },

Some files were not shown because too many files have changed in this diff Show more