Skip to content

MarkDown Editor: TypeScript Conversion & Plaintext Editor #5725

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jul 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dev/build/esbuild.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const entryPoints = {
app: path.join(__dirname, '../../resources/js/app.ts'),
code: path.join(__dirname, '../../resources/js/code/index.mjs'),
'legacy-modes': path.join(__dirname, '../../resources/js/code/legacy-modes.mjs'),
markdown: path.join(__dirname, '../../resources/js/markdown/index.mjs'),
markdown: path.join(__dirname, '../../resources/js/markdown/index.mts'),
wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'),
};

Expand Down
1 change: 1 addition & 0 deletions lang/en/entities.php
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@
'pages_md_insert_drawing' => 'Insert Drawing',
'pages_md_show_preview' => 'Show preview',
'pages_md_sync_scroll' => 'Sync preview scroll',
'pages_md_plain_editor' => 'Plaintext editor',
'pages_drawing_unsaved' => 'Unsaved Drawing Found',
'pages_drawing_unsaved_confirm' => 'Unsaved drawing data was found from a previous failed drawing save attempt. Would you like to restore and continue editing this unsaved drawing?',
'pages_not_in_chapter' => 'Page is not in a chapter',
Expand Down
717 changes: 392 additions & 325 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"devDependencies": {
"@eslint/js": "^9.21.0",
"@lezer/generator": "^1.7.2",
"@types/markdown-it": "^14.1.2",
"@types/sortablejs": "^1.15.8",
"chokidar-cli": "^3.0",
"esbuild": "^0.25.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
import {Component} from './component';
import {EntitySelector, EntitySelectorEntity, EntitySelectorSearchOptions} from "./entity-selector";
import {Popup} from "./popup";

export type EntitySelectorPopupCallback = (entity: EntitySelectorEntity) => void;

export class EntitySelectorPopup extends Component {

protected container!: HTMLElement;
protected selectButton!: HTMLElement;
protected selectorEl!: HTMLElement;

protected callback: EntitySelectorPopupCallback|null = null;
protected selection: EntitySelectorEntity|null = null;

setup() {
this.container = this.$el;
this.selectButton = this.$refs.select;
this.selectorEl = this.$refs.selector;

this.callback = null;
this.selection = null;

this.selectButton.addEventListener('click', this.onSelectButtonClick.bind(this));
window.$events.listen('entity-select-change', this.onSelectionChange.bind(this));
window.$events.listen('entity-select-confirm', this.handleConfirmedSelection.bind(this));
}

/**
* Show the selector popup.
* @param {Function} callback
* @param {EntitySelectorSearchOptions} searchOptions
*/
show(callback, searchOptions = {}) {
show(callback: EntitySelectorPopupCallback, searchOptions: Partial<EntitySelectorSearchOptions> = {}) {
this.callback = callback;
this.getSelector().configureSearchOptions(searchOptions);
this.getPopup().show();
Expand All @@ -32,34 +38,28 @@ export class EntitySelectorPopup extends Component {
this.getPopup().hide();
}

/**
* @returns {Popup}
*/
getPopup() {
return window.$components.firstOnElement(this.container, 'popup');
getPopup(): Popup {
return window.$components.firstOnElement(this.container, 'popup') as Popup;
}

/**
* @returns {EntitySelector}
*/
getSelector() {
return window.$components.firstOnElement(this.selectorEl, 'entity-selector');
getSelector(): EntitySelector {
return window.$components.firstOnElement(this.selectorEl, 'entity-selector') as EntitySelector;
}

onSelectButtonClick() {
this.handleConfirmedSelection(this.selection);
}

onSelectionChange(entity) {
this.selection = entity;
if (entity === null) {
onSelectionChange(entity: EntitySelectorEntity|{}) {
this.selection = (entity.hasOwnProperty('id') ? entity : null) as EntitySelectorEntity|null;
if (!this.selection) {
this.selectButton.setAttribute('disabled', 'true');
} else {
this.selectButton.removeAttribute('disabled');
}
}

handleConfirmedSelection(entity) {
handleConfirmedSelection(entity: EntitySelectorEntity|null): void {
this.hide();
this.getSelector().reset();
if (this.callback && entity) this.callback(entity);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,36 @@
import {onChildEvent} from '../services/dom.ts';
import {onChildEvent} from '../services/dom';
import {Component} from './component';

/**
* @typedef EntitySelectorSearchOptions
* @property entityTypes string
* @property entityPermission string
* @property searchEndpoint string
* @property initialValue string
*/

/**
* Entity Selector
*/
export interface EntitySelectorSearchOptions {
entityTypes: string;
entityPermission: string;
searchEndpoint: string;
initialValue: string;
}

export type EntitySelectorEntity = {
id: number,
name: string,
link: string,
};

export class EntitySelector extends Component {
protected elem!: HTMLElement;
protected input!: HTMLInputElement;
protected searchInput!: HTMLInputElement;
protected loading!: HTMLElement;
protected resultsContainer!: HTMLElement;

protected searchOptions!: EntitySelectorSearchOptions;

protected search = '';
protected lastClick = 0;

setup() {
this.elem = this.$el;

this.input = this.$refs.input;
this.searchInput = this.$refs.search;
this.input = this.$refs.input as HTMLInputElement;
this.searchInput = this.$refs.search as HTMLInputElement;
this.loading = this.$refs.loading;
this.resultsContainer = this.$refs.results;

Expand All @@ -29,9 +41,6 @@ export class EntitySelector extends Component {
initialValue: this.searchInput.value || '',
};

this.search = '';
this.lastClick = 0;

this.setupListeners();
this.showLoading();

Expand All @@ -40,16 +49,13 @@ export class EntitySelector extends Component {
}
}

/**
* @param {EntitySelectorSearchOptions} options
*/
configureSearchOptions(options) {
configureSearchOptions(options: Partial<EntitySelectorSearchOptions>): void {
Object.assign(this.searchOptions, options);
this.reset();
this.searchInput.value = this.searchOptions.initialValue;
}

setupListeners() {
setupListeners(): void {
this.elem.addEventListener('click', this.onClick.bind(this));

let lastSearch = 0;
Expand All @@ -67,7 +73,7 @@ export class EntitySelector extends Component {
});

// Keyboard navigation
onChildEvent(this.$el, '[data-entity-type]', 'keydown', event => {
onChildEvent(this.$el, '[data-entity-type]', 'keydown', ((event: KeyboardEvent) => {
if (event.ctrlKey && event.code === 'Enter') {
const form = this.$el.closest('form');
if (form) {
Expand All @@ -83,7 +89,7 @@ export class EntitySelector extends Component {
if (event.code === 'ArrowUp') {
this.focusAdjacent(false);
}
});
}) as (event: Event) => void);

this.searchInput.addEventListener('keydown', event => {
if (event.code === 'ArrowDown') {
Expand All @@ -93,10 +99,10 @@ export class EntitySelector extends Component {
}

focusAdjacent(forward = true) {
const items = Array.from(this.resultsContainer.querySelectorAll('[data-entity-type]'));
const items: (Element|null)[] = Array.from(this.resultsContainer.querySelectorAll('[data-entity-type]'));
const selectedIndex = items.indexOf(document.activeElement);
const newItem = items[selectedIndex + (forward ? 1 : -1)] || items[0];
if (newItem) {
if (newItem instanceof HTMLElement) {
newItem.focus();
}
}
Expand Down Expand Up @@ -132,7 +138,7 @@ export class EntitySelector extends Component {
}

window.$http.get(this.searchUrl()).then(resp => {
this.resultsContainer.innerHTML = resp.data;
this.resultsContainer.innerHTML = resp.data as string;
this.hideLoading();
});
}
Expand All @@ -142,15 +148,15 @@ export class EntitySelector extends Component {
return `${this.searchOptions.searchEndpoint}?${query}`;
}

searchEntities(searchTerm) {
searchEntities(searchTerm: string) {
if (!this.searchOptions.searchEndpoint) {
throw new Error('Search endpoint not set for entity-selector load');
}

this.input.value = '';
const url = `${this.searchUrl()}&term=${encodeURIComponent(searchTerm)}`;
window.$http.get(url).then(resp => {
this.resultsContainer.innerHTML = resp.data;
this.resultsContainer.innerHTML = resp.data as string;
this.hideLoading();
});
}
Expand All @@ -162,16 +168,16 @@ export class EntitySelector extends Component {
return answer;
}

onClick(event) {
const listItem = event.target.closest('[data-entity-type]');
if (listItem) {
onClick(event: MouseEvent) {
const listItem = (event.target as HTMLElement).closest('[data-entity-type]');
if (listItem instanceof HTMLElement) {
event.preventDefault();
event.stopPropagation();
this.selectItem(listItem);
}
}

selectItem(item) {
selectItem(item: HTMLElement): void {
const isDblClick = this.isDoubleClick();
const type = item.getAttribute('data-entity-type');
const id = item.getAttribute('data-entity-id');
Expand All @@ -180,14 +186,14 @@ export class EntitySelector extends Component {
this.unselectAll();
this.input.value = isSelected ? `${type}:${id}` : '';

const link = item.getAttribute('href');
const name = item.querySelector('.entity-list-item-name').textContent;
const data = {id: Number(id), name, link};
const link = item.getAttribute('href') || '';
const name = item.querySelector('.entity-list-item-name')?.textContent || '';
const data: EntitySelectorEntity = {id: Number(id), name, link};

if (isSelected) {
item.classList.add('selected');
} else {
window.$events.emit('entity-select-change', null);
window.$events.emit('entity-select-change');
}

if (!isDblClick && !isSelected) return;
Expand All @@ -200,7 +206,7 @@ export class EntitySelector extends Component {
}
}

confirmSelection(data) {
confirmSelection(data: EntitySelectorEntity) {
window.$events.emit('entity-select-confirm', data);
}

Expand Down
4 changes: 4 additions & 0 deletions resources/js/components/image-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ export class ImageManager extends Component {
});
}

/**
* @param {({ thumbs: { display: string; }; url: string; name: string; }) => void} callback
* @param {String} type
*/
show(callback, type = 'gallery') {
this.resetAll();

Expand Down
4 changes: 3 additions & 1 deletion resources/js/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ declare global {
baseUrl: (path: string) => string;
importVersioned: (module: string) => Promise<object>;
}
}
}

export type CodeModule = (typeof import('./code/index.mjs'));
Loading